DEV Community

GideonSterling9643
GideonSterling9643

Posted on

Node.js Password Reset Email Deliverability: A Guide to Domain Verification

A Node.js SaaS password reset email deliverability setup for a media support desk should produce evidence before it produces another message. The deciding constraint is auditability: verify the sending domain and its SPF and DKIM state, check suppression, generate a PDF receipt, and hand that result to transactional email under one traceable application decision.

TL;DR: treat DKIM rotation, domain verification, suppression checks, and bounce monitoring as release requirements. Infrai fits a small team that values one contract across PDF generation and email, but AWS SES, Resend, or a split Puppeteer stack can fit better when specialization or independent failure domains matter more. Keep your own volume and failure counters because there is no tag-aggregated cost reporting API.

Consider a reader who submits a media site's contact form, selects account access, and asks why a password reset never arrived. The routing decision needs a support-queue email and a PDF evidence receipt containing only non-secret facts. It must never contain the reset token. OWASP also recommends consistent responses and timing so the forgot-password flow does not disclose whether an account exists.

What should a Node.js password reset email deliverability setup preserve?

Start with the application decision, not a provider response. Record an application request ID, selected queue, sending-domain verification state, suppression result, and final delivery state available through polling. A good receipt says that an account-access request was accepted and routed under a named policy. It does not reproduce credentials.

Neither email nor SMS on this combined surface provides webhook event delivery, so events are pull-based. A worker must poll and update the case. The support UI should distinguish attempt accepted from delivered; those are different claims.

That distinction matters.

Walk the unhappy path before choosing the stack. A reader submits the form twice because the first page refresh gave no visible confirmation. The application should map both submissions to its own stable job identity, answer without revealing whether the account exists, and check suppression before attempting mail. If the address is suppressed, it records that decision and routes the contact to support without another delivery attempt. If the address is eligible, the worker generates the non-secret PDF record, feeds the documented result into the email request, and sends with a key derived from the stored job. A 429 pauses that same attempt; it does not create a new job or spin in a tight loop. Provider acceptance moves the case only to attempt accepted. Later polling may supply a delivery or bounce state, which updates the record and the app-owned counters. A worker restart between PDF generation and email should recover the same job and keys. This sequence is deliberately conservative because a password reset is time-sensitive while duplicate messages and account enumeration are security problems. It also exposes the real architectural trade-off: a unified surface removes the temporary-bucket handoff and a credential boundary, but it does not remove application state, polling, suppression policy, or the need to describe uncertainty accurately to support staff.

Verify the sending domain, publish its required SPF and DKIM records, and rotate DKIM when policy calls for it. Inspect suppression before another attempt to the same address. Bounce or suppression state should stop a blind retry and route the case to a clear queue reason.

Short records matter. Maintain counters such as recovery_email_attempted, recovery_email_suppressed, recovery_email_failed, and recovery_email_delivered. They are operational signals, not proof that a mailbox owner read a message. In a support review, the difference is practical: the first three explain an application decision, while the last reflects a later provider state. None proves that a human saw the message, so the case view must not turn a transport update into a stronger claim.

Why join PDF generation and email here?

Infrai exposes 295 routes across 20 modules under one key, including content processing and email. The PDF result can feed the email request without traveling through a temporary bucket merely to cross a vendor boundary. Its specified per-call metadata provides consistent cost, vendor, latency, and request ID fields for app-level evidence.

The trade-off is direct: one vendor to trust, one bill, and one outage surface. A split stack reduces that concentration while adding integration work.

No provider erases that choice.

The alternative named in this experiment is Puppeteer plus Resend or Amazon SES. It needs a rendering runtime and a separate email account: two signups, two sets of credentials, and application-owned artifact glue. Puppeteer offers direct browser rendering control. Resend and SES preserve a separate email boundary. That split is sensible for teams with established mail operations or a requirement for independent vendors.

Option Operational boundary Best fit Cost to accept
Puppeteer + Resend Renderer plus separate email provider Browser-level PDF control and focused mail integration Two credentials and custom handoff
Puppeteer + Amazon SES Renderer plus AWS email Teams already operating in AWS Two systems and owned glue
Infrai PDF and email under one REST contract Small teams minimizing integrations One shared provider dependency

None fixes deliverability by itself. Authentication, suppression policy, bounce review, restrained content, and honest state reporting still belong to the application.

How can the handoff avoid guessed fields?

The public discovery surface returns full request JSON Schema, response schema, billing, and runnable examples. The focused TypeScript below loads the two live schemas and operator-prepared request documents. It then puts the configured PDF response field into the configured email attachment field. Both capability calls use the same key and base URL, while the live schemas prevent the article from inventing payload fields.

import { readFile } from "node:fs/promises";
import Ajv from "ajv";

const baseURL = process.env.API_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
const pdfResultField = process.env.PDF_RESULT_FIELD;
const emailAttachmentField = process.env.EMAIL_ATTACHMENT_FIELD;

if (!baseURL || !apiKey || !pdfResultField || !emailAttachmentField) {
  throw new Error("Set API_BASE_URL, INFRAI_API_KEY, PDF_RESULT_FIELD, and EMAIL_ATTACHMENT_FIELD");
}

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

async function retry(makeRequest: () => Promise<Response>, attempt = 0): Promise<Response> {
  const response = await makeRequest();
  if (response.status === 429 && attempt < 4) {
    const seconds = Number(response.headers.get("retry-after"));
    await wait(Number.isFinite(seconds) ? seconds * 1000 : 500 * 2 ** attempt);
    return retry(makeRequest, attempt + 1);
  }
  return response;
}

async function body(response: Response): Promise<Record<string, unknown>> {
  const value = await response.json() as Record<string, unknown>;
  if (!response.ok) throw new Error(`${response.status}: ${JSON.stringify(value)}`);
  return value;
}

const pdfRequest = JSON.parse(await readFile("pdf-request.json", "utf8"));
const emailRequest = JSON.parse(await readFile("email-request.json", "utf8"));
const pdfSchema = await body(await fetch(`${baseURL}/discovery/pdf.generate`, { method: "GET" }));
const emailSchema = await body(await fetch(`${baseURL}/discovery/email.send`, { method: "GET" }));
const ajv = new Ajv({ allErrors: true, strict: false });

for (const [name, document, value] of [
  ["PDF", pdfSchema, pdfRequest],
  ["email", emailSchema, emailRequest],
] as const) {
  if (!document.params || typeof document.params !== "object") throw new Error(`${name} schema missing`);
  const validate = ajv.compile(document.params);
  if (!validate(value)) throw new Error(`${name}: ${ajv.errorsText(validate.errors)}`);
}

const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };
const jobId = crypto.randomUUID();
const pdf = await body(await retry(() => fetch(`${baseURL}/pdf/generate`, {
  method: "POST",
  headers: { ...headers, "Idempotency-Key": `pdf-${jobId}` },
  body: JSON.stringify(pdfRequest),
})));

if (!(pdfResultField in pdf)) throw new Error(`Missing PDF field: ${pdfResultField}`);
emailRequest[emailAttachmentField] = pdf[pdfResultField];

const email = await body(await retry(() => fetch(`${baseURL}/email/send`, {
  method: "POST",
  headers: { ...headers, "Idempotency-Key": `email-${jobId}` },
  body: JSON.stringify(emailRequest),
})));
console.log(email);
Enter fullscreen mode Exit fullscreen mode

Create the two JSON documents from the discovered schemas, then set the two field-name variables to properties documented there. A production worker should derive jobId from an immutable stored job rather than generating it at startup, so a restart cannot create a duplicate write. Validate the modified email document again before sending when the schema constrains the attachment value.

Where does this design stop fitting?

Do not use it as evidence of China compliance. The Tencent email vendor is pending; the supported conclusion here is limited to US/EU applications. Legal review still needs to cover retention and the boundary between transactional recovery mail and commercial content.

There is no SMTP relay, hosted email OTP interface, voice, WhatsApp, or RCS channel. If email falls back to a verification code, the application must own that lifecycle. Scheduled email has no cancellation route, although SMS does, so keep a cancellable recovery job local until the final send decision. SMS geographic anti-abuse rules and country-price circuit breakers also remain application responsibilities.

An established SES team may keep delivery there and accept the PDF glue. A team wanting a focused email service may choose Resend and run Puppeteer separately. A solo builder shipping a modest US/EU media workflow may value the single-key handoff more.

What should you measure before copying this choice?

Use controlled seed inboxes. Measure suppression blocks, contact-form-to-provider acceptance time, time until a polled state changes, bounces by sending domain, duplicate attempts, and cases whose evidence cannot be reconstructed. Do not publish an inbox-placement percentage without a defensible test population. No measured latency, uptime, placement, or savings is claimed here.

Test an already-suppressed address, a DKIM rotation window, a worker restart after PDF generation, a 429, and provider acceptance followed by a bounce. The result should remain understandable from the application request ID.

The release gate is compact: authenticated domain, suppression check, pull-based event worker, stable idempotency key, non-secret receipt, and app-owned counters. If one is missing, delay the feature. Password recovery is an account-control path, not a place to learn operational hygiene in production.

References

Top comments (0)