When a password reset email link is missing or broken, inspect the rendered HTML before blaming the email client. A reset URL is ready to ship only when the exact rendered message, not the template or payload object, has passed an assertion. For a property-management system that emails generated inspection reports, keep report delivery and account recovery as separate message types, then give both the same preflight boundary. The manager who cannot recover access cannot retrieve the report anyway.
| Choice | What gets checked | Evidence quality | Best fit |
|---|---|---|---|
| Render, parse, then enqueue | Final HTML and plain text | Strong: tests the artifact handed to delivery | Default |
| Assert template inputs | Token and base URL before rendering | Medium: misses template damage | Small text-only messages |
| Inspect delivered copies | What arrived in test inboxes | Strongest end-to-end signal, but slower | Release checks and client regressions |
Recommendation: make render-and-parse the blocking check, and use delivered-copy inspection as the runner-up release check. This catches a missing href, double interpolation, and HTML attribute encoding before a queue accepts the message.
TL;DR: construct the recovery URL with Node.js URL APIs, escape it once for the HTML attribute, include the unescaped absolute URL in plain text, and reject the message before enqueueing unless both representations contain the same destination. Do not use successful provider acceptance as proof that the link works.
Why is the password reset email link missing or broken?
Delivery and usability are different checkpoints. Google and Yahoo publish sender requirements and best practices aimed at mail acceptance and sender reputation. Those controls matter, but a message can satisfy delivery requirements while its application-owned template emits an empty, truncated, or wrongly encoded destination.
The useful ownership line is the serialized message. A queue receipt proves that a job was accepted. A delivery event proves movement through mail infrastructure. Neither proves that {{resetUrl}} became a usable anchor in the HTML a recipient opened.
Property workflows make this distinction sharp. A generated inspection report may be a PDF attachment, while the password recovery message should contain no report and no tenant data. They can share rendering, preflight, idempotency, and observability code without sharing content. That separation limits what a recovery failure can expose and makes a retry easier to reason about.
Short paths win.
One boundary. One artifact.
Criterion one: validate the final artifact
Build an absolute URL from a configured public origin. Let the URL implementation encode the token as data. Then escape the completed URL for one context only: an HTML attribute. Hand-concatenating ?token= looks harmless until a value contains a reserved character or the template engine escapes an already escaped string.
This TypeScript example has no mail-vendor dependency. The transport receives a message only after preflight succeeds. The same interface can carry a monthly property report, but the recovery message deliberately has no attachments.
import { randomBytes } from "node:crypto";
type Message = {
to: string;
subject: string;
text: string;
html: string;
attachments?: Array<{ filename: string; content: Uint8Array }>;
};
type Transport = { enqueue(message: Message): Promise<{ id: string }> };
const escapeAttribute = (value: string): string =>
value.replaceAll("&", "&")
.replaceAll('"', """)
.replaceAll("<", "<")
.replaceAll(">", ">");
function recoveryUrl(publicOrigin: string, token: string): URL {
const url = new URL("/account/recover", publicOrigin);
url.searchParams.set("token", token);
return url;
}
function renderRecovery(to: string, url: URL): Message {
const raw = url.toString();
const href = escapeAttribute(raw);
return {
to,
subject: "Restore access to your property reports",
text: `Open this link to restore access:\n\n${raw}`,
html: `<p>Open this link to restore access:</p><p><a href="${href}">Restore access</a></p>`,
};
}
function assertRecoveryMessage(message: Message, expected: URL): void {
const raw = expected.toString();
const exactAnchor = `href="${escapeAttribute(raw)}"`;
if (!message.html.includes(exactAnchor)) {
throw new Error("Recovery HTML does not contain the expected destination");
}
if (!message.text.includes(raw)) {
throw new Error("Recovery text does not contain the expected destination");
}
if (message.attachments?.length) {
throw new Error("Recovery messages must not contain property attachments");
}
}
export async function sendRecovery(
transport: Transport,
publicOrigin: string,
to: string,
): Promise<{ id: string }> {
const token = randomBytes(32).toString("base64url");
const url = recoveryUrl(publicOrigin, token);
const message = renderRecovery(to, url);
assertRecoveryMessage(message, url);
return transport.enqueue(message);
}
The important detail is boring: raw and its HTML representation are separate values. The plain-text part gets the absolute URL. The HTML part gets its attribute-safe representation. No global sanitizer gets to guess the context. The 32-byte random token in the example becomes a base64url string, then URLSearchParams treats that entire string as one query value. The renderer does not decode it, rewrite it, or concatenate another query fragment. That narrow data path gives a failed assertion one obvious owner.
Do not log either value. Record a message identifier, template revision, message type, enqueue result, and timestamps instead. The token grants access; treating it as routine diagnostic text turns observability into a credential leak.
Criterion two: make failures reproducible
A useful regression test feeds hostile-looking data through the real renderer. It does not mock away URL construction. Benchmark this boundary by counting moving parts before looking at throughput: one constructor, one renderer, one assertion, one transport call. More configuration means more states that tests must cover.
import assert from "node:assert/strict";
import test from "node:test";
const cases = [
"abc123",
"segment/with/slashes",
"value+with&delimiters",
"quote\"and<markup>",
];
test("HTML and text preserve every recovery token", () => {
for (const token of cases) {
const url = recoveryUrl("https://portal.example/", token);
const message = renderRecovery("manager@example.test", url);
assert.doesNotThrow(() => assertRecoveryMessage(message, url));
assert.equal(url.searchParams.get("token"), token);
}
});
test("recovery mail cannot inherit a report attachment", () => {
const url = recoveryUrl("https://portal.example/", "token");
const message = renderRecovery("manager@example.test", url);
message.attachments = [
{ filename: "inspection-report.pdf", content: new Uint8Array() },
];
assert.throws(() => assertRecoveryMessage(message, url));
});
Four fixtures expose distinct character classes without turning the test into a fake benchmark. Add a regression fixture whenever production parsing finds a new class of failure. Keep the original input, expected absolute destination, rendered HTML, and rendered text together so a failure can be reproduced offline. If the plain-text URL passes while the HTML assertion fails, inspect attribute escaping and template interpolation. If both local forms pass but the received source differs, move the investigation downstream to MIME assembly or link rewriting. If the received source matches but activation fails, stop editing email HTML; the application-side token lookup, expiry, or consumption path now owns the failure. This three-way split is more useful than swapping templates and resending until one happens to work.
Do not guess.
Client coverage comes next. Store sanitized rendered fixtures from release tests and check them in the clients your recipients actually use. That is a deployment gate, not a reason to put client-specific branches into the template. A single plain anchor plus a complete text fallback is easier to inspect than a component tree full of conditional markup.
Implementation flow and operating signals
The request path should issue a short-lived, single-use credential in the application's own datastore, render both bodies, run preflight, and enqueue the immutable message. On use, the application validates the credential and marks it consumed. The email system should never decide whether a token is valid.
For retries, reuse the same logical message record while allowing a new transport attempt identifier. This separates “the manager requested recovery once” from “delivery was attempted twice.” Alert on preflight rejection separately from delivery delay: one points to code or configuration, the other to the transport path.
Track counts and latency by message type and template revision. Avoid recipient addresses, reset destinations, and attachment contents in metrics labels. For the report workflow, record attachment byte count and a content digest in internal job state; for recovery, record that attachment count is zero. Those fields answer operational questions without copying sensitive content into dashboards.
The deployment check is plain. Render against the production public origin in a non-user test account, verify HTML and text, send to controlled inboxes, and follow the received link. A green unit suite cannot observe rewriting after enqueue. A delivered-copy check can.
That last check costs time.
When is delivered-copy inspection the better first choice?
Choose the runner-up first when a migration changes the sending domain, MIME assembly, link-rewriting layer, or supported email clients. The uncertainty then sits after rendering. Inspecting only the local artifact would stop too early.
It is also the right diagnostic move when the stored rendered message contains the expected URL but recipients report a different destination. Preserve a sanitized copy from before enqueue and compare it with the received source. That comparison locates the mutation boundary without guessing. Do not disable encoding or signature checks just to make one sample pass.
For ordinary template edits, delivered-copy testing alone is too slow and too remote. Keep it as a release gate. Let deterministic preflight block every bad message at the cheaper boundary, then use inbox evidence to cover the infrastructure you do not own.
This approach has limits. String assertions are not appropriate when a template produces several legitimate links, varies markup by locale, or is transformed after the application renders it; parse the document and identify the recovery anchor by a stable semantic marker instead. The trade-off is another parser dependency and a larger test surface. Delivered-copy inspection is slower and can be nondeterministic, but it is the better choice when the suspected mutation happens beyond the renderer. Neither check proves that the application will accept an expired or already consumed token.
The decision rule stays narrow: prove the destination in the final local artifact on every send, and prove the received artifact whenever downstream behavior changes. That gives a property manager a working recovery route to the report archive without coupling authentication mail to the report attachment itself.
Top comments (0)