DEV Community

ElowenVeil9067
ElowenVeil9067

Posted on

Node.js Password Reset Email Preflight for Links, HTML Templates, and Compliance Evidence

When a password reset email arrives with its link missing, an e-commerce support team needs more than a Node.js send result: it needs evidence of the rendered HTML without exposing the reset token itself.

Short answer: validate the final HTML and visible HTTPS fallback link before sending, retain a redacted preview as compliance evidence, then inspect the stored sent message and poll delivery events when a customer reports a blank or malformed email.

This changes the vendor decision. I care less about a glossy editor than I do about a repeatable chain from template input to rendered output to message record. A solo SaaS has finite engineering hours, and password recovery is undifferentiated work. I want the boring parts outsourced so the weekly release still happens.

How can Node.js prove a password reset email link survived HTML?

Start before the email client. A missing link can enter the system as a missing template variable, a relative URL, an incorrectly escaped value, or HTML that doesn't preserve a readable fallback. If the final rendered content is archived in redacted form before dispatch, those cases become ordinary input failures instead of arguments about Gmail, Yahoo Mail, or a customer's corporate filter.

The order matters:

  1. Generate the short-lived reset token in the application. Email isn't the token authority.
  2. Build one absolute HTTPS URL and reject any unexpected origin.
  3. Render the template with real-shaped test data, including a token containing characters that require URL encoding.
  4. Assert that both the button target and a plain, visible fallback link contain the same encoded destination.
  5. Store a redacted preview digest, template revision, recipient identifier, and message ID as evidence.
  6. After sending, fetch the recorded message. Poll its events if a user reports malformed or blank content.

Don't log the live token. A useful audit record proves that the URL structure, origin, expiry metadata, and template revision passed validation; it doesn't need to become a second credential store. Keep raw tokens out of analytics too.

I'm not sure which email client mix your store will have six months from now. Nobody is. The visible fallback is cheap insurance because it gives a customer something copyable when a client strips styling or rewrites the button markup.

Build log with a preflight receipt and a message receipt

This is the part I would keep in the application even when the provider offers template preview. It makes the security boundary explicit, runs in CI, and prevents a vendor migration from changing the acceptance test.

import { createHash } from "node:crypto";

type ResetMessage = {
  html: string;
  audit: {
    templateRevision: string;
    recipientId: string;
    resetUrlDigest: string;
    expiresAt: string;
  };
};

const allowedOrigin = "https://shop.example.com";
const apiBaseUrl = process.env.INFRAI_API_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;

function escapeHtml(value: string): string {
  return value
    .replaceAll("&", "&")
    .replaceAll('"', """)
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;");
}

function buildResetMessage(input: {
  resetToken: string;
  recipientId: string;
  templateRevision: string;
  expiresAt: Date;
}): ResetMessage {
  if (!input.resetToken) throw new Error("RESET_TOKEN_MISSING");

  const resetUrl = new URL("/account/password/reset", allowedOrigin);
  resetUrl.searchParams.set("token", input.resetToken);

  if (resetUrl.protocol !== "https:" || resetUrl.origin !== allowedOrigin) {
    throw new Error("RESET_URL_ORIGIN_REJECTED");
  }

  const safeUrl = escapeHtml(resetUrl.toString());
  const html = [
    "<p>We received a request to reset your password.</p>",
    `<p><a href="${safeUrl}">Reset password</a></p>`,
    "<p>If the button does not work, copy this link:</p>",
    `<p>${safeUrl}</p>`,
    `<p>This link expires at ${escapeHtml(input.expiresAt.toISOString())}.</p>`,
  ].join("\n");

  const occurrences = html.split(safeUrl).length - 1;
  if (occurrences !== 2) throw new Error("RESET_LINK_RENDER_COUNT_INVALID");

  return {
    html,
    audit: {
      templateRevision: input.templateRevision,
      recipientId: input.recipientId,
      resetUrlDigest: createHash("sha256").update(resetUrl.toString()).digest("hex"),
      expiresAt: input.expiresAt.toISOString(),
    },
  };
}

async function fetchSentMessage(messageId: string): Promise<unknown> {
  if (!apiBaseUrl || !apiKey) {
    throw new Error("INFRAI_API_BASE_URL_AND_INFRAI_API_KEY_REQUIRED");
  }

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(
      `${apiBaseUrl}/v1/email/get/${encodeURIComponent(messageId)}`,
      {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}` },
      },
    );

    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.text();
    if (!response.ok) {
      throw new Error(`EMAIL_GET_FAILED_${response.status}: ${body}`);
    }

    return body ? JSON.parse(body) : null;
  }

  throw new Error("EMAIL_GET_RATE_LIMIT_RETRIES_EXHAUSTED");
}

async function main(): Promise<void> {
  const messageId = process.env.INFRAI_EMAIL_MESSAGE_ID;
  if (!messageId) throw new Error("INFRAI_EMAIL_MESSAGE_ID_REQUIRED");

  const message = buildResetMessage({
    resetToken: "sample+/=token",
    recipientId: "customer_4821",
    templateRevision: "reset-email-17",
    expiresAt: new Date("2026-08-20T10:15:00.000Z"),
  });

  console.log(message.html);
  console.log(message.audit);
  console.log(await fetchSentMessage(messageId));
}

main().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

Two details are easy to miss. URLSearchParams owns URL encoding, while escapeHtml owns HTML attribute safety; treating those as one operation is how a valid token turns into a broken destination. The assertion also expects the same URL twice. One occurrence is the button, and the other is the fallback. Simple.

The fixed timestamp and odd-looking sample token are test fixtures, not production policy. In production, calculate expiry from your own security policy and pass it into the renderer. A unit test should also try an empty token, an altered origin, and a template revision where the fallback was accidentally removed. Those are useful failures: RESET_TOKEN_MISSING, RESET_URL_ORIGIN_REJECTED, and RESET_LINK_RENDER_COUNT_INVALID point to the stage that rejected the message.

Four providers face the same evidence test

SendGrid, Postmark, Resend, and Infrai can all sit downstream of the same local preflight. The meaningful comparison for this job is ownership of the evidence path, not a feature-count contest.

Option Best fit Evidence and ownership trade-off
SendGrid A store already standardized on Twilio SendGrid templates and operational tooling Keep it when migration would split existing delivery history from the team that handles support. Verify the rendered-template and activity-retention workflow against current account settings.
Postmark A product already organized around Postmark templates and message streams Keep it when those streams are part of the incident trail. Confirm how long message content and events remain available for the compliance window.
Resend A Node.js team whose template source is already maintained with its Resend integration Keep it when changing providers would create more template ownership work than it removes. Test final HTML in the actual client matrix rather than treating source markup as proof.
Infrai A small application that values a stable HTTP boundary across backend vendors Its plain REST contract lets the provider behind a capability change without application code changing, while one key covers a broader backend surface. For this flow, preview with POST /v1/email/template/preview/{id}, send through the documented email capability, fetch the sent record with GET /v1/email/get/{id}, and poll events; there is no real-time webhook debugging path. It also has no hosted email OTP endpoint, so the application must generate a token or code.

That final row is attractive when I want one narrow adapter and less SDK maintenance. The catch is the pull-based event model: it is not suitable when a strict, near-real-time webhook pipeline is the compliance control. Stick with a provider whose existing webhook workflow your team already operates in that case. Also keep the incumbent when historical evidence continuity matters more than provider portability.

There are harder boundaries. This route is email, not SMTP relay, voice, WhatsApp, or RCS. A scheduled email doesn't have a cancellation route. Domestic email vendor support is pending, so it cannot be used as evidence for a China-specific compliance decision. Those constraints can outweigh a tidy API, and they should be written into the architecture decision record before implementation starts.

The compliance ledger is deliberately incomplete

The audit object in the example is a starting point, not the whole record. I would retain the template revision, an internal recipient ID, the computed expiry, a digest of the complete reset URL, the preflight result, the provider message ID, and timestamps from subsequent event polling. Access to those records should be narrower than access to ordinary product analytics.

I would not retain the live reset token or an unredacted body just because storage is easy. That creates a credential-handling problem while adding little diagnostic value. If policy requires preserving exact content, encrypt it, set a deliberate retention period, and document who can decrypt it. The precise retention window depends on the store's jurisdiction and policy; this article can't determine that. Legal and security owners can.

My first assumption in this design was that a 202 Accepted or 200 OK could be the receipt. It can't. The status proves only that one boundary accepted the request; it doesn't prove that the template contained the link, that a mailbox displayed it, or that the customer used it before expiry. Separate those states in support tooling. Otherwise every password-reset ticket becomes a hunt through one misleading “sent” flag.

Evidence should answer a narrow sequence: which template revision rendered, did preflight accept the exact destination structure, which provider message record corresponds to it, and what later events were observed? That is enough for useful troubleshooting without exposing credentials.

Scale changes the polling loop, not the proof

At low volume, polling after a customer report and on a modest background schedule is manageable. At scale, I would put event polling behind a queue, deduplicate by provider event identity, and alert on age rather than hammering the API. Respect 429 Too Many Requests and Retry-After, then use exponential backoff. The worker must be restartable because duplicate delivery of queue work is normal in many systems.

The client matrix grows too. Add captured fixtures for the mailbox clients that account for actual support volume, but keep the local contract unchanged: absolute HTTPS URL, correctly encoded token, button plus visible fallback, redacted evidence. Your mileage may vary on exactly which clients deserve a fixture. Let ticket data decide.

I would revisit the vendor choice when real-time evidence becomes mandatory, when the business needs a channel the current provider doesn't support, or when compliance requires a region or domestic vendor whose readiness is confirmed. Until then, switching for marginal feature differences burns the hours that should ship revenue-producing work.

Ship the check first.

References

Top comments (0)