A password reset email has one constraint that changes the design: every recipient needs a different, short-lived reset value, but the message must be testable before any real inbox sees it. Keep the application team responsible for the template contract. Validate every variable, render a preview with synthetic data, then hand a complete message to the delivery adapter.
TL;DR: define one typed model, reject absent and unknown placeholders before sending, and test the HTML, text, headers, and link. The delivery API should not be the first component to discover that reset_url was spelled resetUrl.
This boundary also matters in a media system that emails generated reports as attachments. Reset emails and reports carry different data, yet both need an explicit answer to one question: who owns the template schema?
Replace the relay with a contract
The fragile model is a relay. The application emits a loose object, a remote template substitutes whatever it recognizes, and an API reports an error if it cannot continue. By then, the preview may not match production and the log may lack the template version needed for diagnosis.
Use a contract instead. Picture the path: request handler, token service, message builder, validator, renderer, delivery adapter. The validator is the gate. Preview and production pass through the same renderer.
Fail early.
Application-owned templates require deployment coordination. Remotely managed templates let content editors change copy faster. That is a genuine trade-off. If remote editing matters, validate each published template's declared variables against the application schema in CI. Fast copy changes should not create an unversioned interface.
For report attachments, the report job owns reportDate, publicationName, the filename, media type, and bytes. The communication layer owns message construction and transport. Keep that contract separate from reset data; one giant object full of optional fields makes missing values look normal.
Why are password reset email template variables missing or malformed?
A preview proves only what it rendered. A browser preview may use fallback values while the send path supplies an empty object. A template editor may retain sample data that the API request never includes. Even correct HTML says nothing about the text part or the reset link's host.
Check four boundaries in order:
- Compare referenced placeholder names with the application's exact keys.
- Distinguish a missing key from an empty value.
- Confirm preview and send call the same builder.
- Inspect the final message before transport, with secrets removed.
Never log a reset token or full reset URL. Log a template version, message kind, validation outcome, correlation ID, and transport request ID. Count local validation failures separately from remote rejections. One red “email failed” counter smears two different owners together.
Build one copyable Node.js boundary
This TypeScript example uses a generic transport. Token creation and expiration policy remain outside the email module.
interface ResetData {
displayName: string;
resetUrl: string;
expiresInMinutes: number;
}
interface EmailTransport {
send(message: {
to: string;
subject: string;
html: string;
text: string;
}): Promise<{ requestId: string }>;
}
const keys: ReadonlyArray<keyof ResetData> = [
"displayName",
"resetUrl",
"expiresInMinutes",
];
function validate(value: unknown): asserts value is ResetData {
if (typeof value !== "object" || value === null) {
throw new Error("Reset template data must be an object");
}
const record = value as Record<string, unknown>;
const missing = keys.filter((key) => record[key] === undefined);
const unknown = Object.keys(record).filter(
(key) => !keys.includes(key as keyof ResetData),
);
if (missing.length || unknown.length) {
throw new Error(
`Template mismatch: missing=${missing.join(",") || "none"}; unknown=${unknown.join(",") || "none"}`,
);
}
if (typeof record.displayName !== "string" || !record.displayName.trim()) {
throw new Error("displayName must be a non-empty string");
}
if (typeof record.expiresInMinutes !== "number" || record.expiresInMinutes <= 0) {
throw new Error("expiresInMinutes must be positive");
}
if (typeof record.resetUrl !== "string") throw new Error("resetUrl must be a string");
if (new URL(record.resetUrl).protocol !== "https:") {
throw new Error("resetUrl must use HTTPS");
}
}
const escapeHtml = (input: string): string =>
input.replace(/[&<>"']/g, (character) => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
})[character]!);
export function renderResetEmail(input: unknown) {
validate(input);
const name = escapeHtml(input.displayName);
const url = escapeHtml(input.resetUrl);
const minutes = input.expiresInMinutes;
return {
subject: "Reset your password",
html: `<p>Hello ${name},</p><p><a href="${url}">Reset your password</a>. This link expires in ${minutes} minutes.</p>`,
text: `Hello ${input.displayName},\n\nReset your password: ${input.resetUrl}\nThis link expires in ${minutes} minutes.`,
};
}
export async function sendResetEmail(
transport: EmailTransport,
to: string,
input: unknown,
) {
return transport.send({ to, ...renderResetEmail(input) });
}
The strict unknown-key check is deliberate. If reset_url survives a rename, silently ignoring it hides a half-finished migration. Test valid data, each omitted key, an old key left behind, and a non-HTTPS URL. Assert one expected link, no unresolved placeholder delimiters, and a non-empty text alternative.
A useful dashboard separates attempted builds, contract failures, accepted transport requests, and delivery events. Acceptance means the transport accepted work; it does not prove inbox placement or user action.
Use low-cardinality labels: message kind, template version, environment, outcome. Keep correlation and request IDs in structured logs. Recipient addresses, URLs, token fragments, and raw payloads do not belong in metric labels.
Render every committed template with synthetic fixtures in CI. Run a canary through the production rendering path without a real reset token. For remote templates, record a version so an alert identifies the exact content that ran.
Editors can own the words without owning an invisible runtime schema. Give them a preview fixture and the allowed variables. Block publication when a referenced variable is absent from the contract. Engineering owns compatibility rules; content owners control subject lines, layout, and explanatory copy within them.
A rename needs a migration: add the new variable, update and verify the template, then remove the old name after every active version has moved. Avoid dangerous fallbacks. Replacing a missing display name with “there” may be valid copy policy; replacing a missing reset URL with # is not.
That is the ownership test: editors can change expression, while the schema remains reviewed code. The same rule covers the newsroom's generated report. A copy edit can rename the visible report label, but changing publicationName, its type, or attachment requirements is an interface migration. Making that distinction explicit costs a little ceremony during editing and removes ambiguity during an incident, which is the trade I would choose for action-bearing email.
Retries do not repair malformed variables. Validate before transport, keep preview and production on one render path, version the contract, and route each signal to the team that can act. Then a missing placeholder becomes a precise test failure instead of a malformed email.
References
- Amazon Simple Email Service Developer Guide: https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- MDN Web Docs, WebOTP API: https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API
Top comments (0)