A malformed password reset email request in a healthtech flow needs more than a generic API error: the message must expire quickly, while the system still needs durable evidence of what it accepted and attempted. I would choose a typed delivery boundary that validates the JSON payload, checks for an invalid sender domain, renders the template before dispatch, and records stage-specific outcomes. One generic send failed event cannot support a useful compliance review.
TL;DR: treat malformed input, sender identity, template rendering, and transport acceptance as four separate failure classes. Reject bad input before any network call, keep reset secrets out of logs, and store identifiers plus reason codes at each boundary. A short expiry belongs in the reset artifact and rendered copy; it should not be inferred from a mail transport response.
This is less convenient than passing a loose object straight to a mail SDK. The simple approach has fewer lines, but it mixes developer mistakes with delivery failures and can leave reviewers unable to tell whether a reset link was rendered correctly. Evidence wins.
How should a malformed password reset email request expose an invalid domain?
The word "invalid" can describe unrelated conditions. The inbound body may not be JSON. It may be valid JSON with a missing recipient. The from value may use a domain that the sending system is not authorized to use. The template may expect expiresAt while the application supplies expiry. Finally, a transport can accept a message after all three earlier checks pass. Those outcomes should never collapse into one log line.
HTTP parsing is only the first boundary. JSON syntax says nothing about the shape or meaning of the parsed value, so runtime validation is still required in a TypeScript service. Type annotations disappear at runtime. For a password reset, validate the recipient, an opaque audit ID, a URL with the expected HTTPS origin, and an explicit expiration timestamp. Do not log the URL because it normally carries the reset secret.
The sender domain is a different concern. DKIM signs selected message content and associates the signature with a signing domain through the d= tag. RFC 6376 also makes clear that a valid signature does not, by itself, tell a receiver whether to trust the signer. Payload validity, domain authorization, cryptographic signing, and recipient policy are separate questions.
Stop there.
Put the evidence boundary before the mail client
This focused example uses generic interfaces so the contract does not inherit one provider's vocabulary. It returns a compact result that an audit event can record without retaining the reset link or rendered body. The allowlist is application configuration; checking its string does not prove DNS authorization.
type ResetInput = {
to: string;
from: string;
resetUrl: string;
expiresAt: string;
auditId: string;
};
type DeliveryResult =
| { ok: true; auditId: string; transportId: string }
| {
ok: false;
auditId?: string;
stage: "parse" | "domain" | "render" | "transport";
code: string;
};
interface MailTransport {
send(message: {
to: string;
from: string;
subject: string;
html: string;
}): Promise<{ id: string }>;
}
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function parseResetInput(value: unknown): ResetInput {
if (!value || typeof value !== "object") throw new Error("BODY_NOT_OBJECT");
const input = value as Record<string, unknown>;
const required = ["to", "from", "resetUrl", "expiresAt", "auditId"] as const;
for (const key of required) {
if (typeof input[key] !== "string" || input[key].length === 0) {
throw new Error(`INVALID_${key.toUpperCase()}`);
}
}
if (!EMAIL.test(input.to as string) || !EMAIL.test(input.from as string)) {
throw new Error("INVALID_ADDRESS");
}
const resetUrl = new URL(input.resetUrl as string);
if (resetUrl.protocol !== "https:" ||
resetUrl.origin !== "https://accounts.example-health.test") {
throw new Error("INVALID_RESET_ORIGIN");
}
const expiry = Date.parse(input.expiresAt as string);
if (!Number.isFinite(expiry) || expiry <= Date.now()) {
throw new Error("INVALID_EXPIRY");
}
return input as ResetInput;
}
function senderDomain(address: string): string {
return address.slice(address.lastIndexOf("@") + 1).toLowerCase();
}
function renderReset(input: ResetInput, now: number): string {
const minutes = Math.max(1,
Math.ceil((Date.parse(input.expiresAt) - now) / 60_000));
return `<p>A password reset was requested.</p>
<p><a href="${input.resetUrl}">Reset password</a></p>
<p>This link expires in ${minutes} minutes.</p>`;
}
async function deliverReset(
raw: unknown,
allowedDomains: ReadonlySet<string>,
transport: MailTransport,
now = Date.now()
): Promise<DeliveryResult> {
let input: ResetInput;
try {
input = parseResetInput(raw);
} catch (error) {
const code = error instanceof Error ? error.message : "INVALID_INPUT";
return { ok: false, stage: "parse", code };
}
if (!allowedDomains.has(senderDomain(input.from))) {
return { ok: false, auditId: input.auditId,
stage: "domain", code: "SENDER_DOMAIN_DENIED" };
}
let html: string;
try {
html = renderReset(input, now);
} catch {
return { ok: false, auditId: input.auditId,
stage: "render", code: "TEMPLATE_RENDER_FAILED" };
}
try {
const receipt = await transport.send({
to: input.to, from: input.from,
subject: "Reset your password", html
});
return { ok: true, auditId: input.auditId, transportId: receipt.id };
} catch {
return { ok: false, auditId: input.auditId,
stage: "transport", code: "TRANSPORT_REJECTED" };
}
}
The regular expression is intentionally modest. It catches obvious payload mistakes rather than claiming full mailbox validation. Exact address rules and internationalization policy need a reviewed component. More important here, the order is observable: no transport call occurs when parsing, origin checking, expiry checking, or the local sender-domain policy fails.
There is a subtle timing trap. Calculating displayed minutes from a fresh clock read can make snapshot tests flaky at a minute boundary. The example injects one instant for rendering, though a production validator should use that same injected clock too. That small dependency is cheaper than explaining why evidence says a link had 9 minutes left while the message said 8.
The main trade-off is extra classification and evidence storage. This synchronous design is not suitable when delivery must survive an application restart or absorb large bursts; choose a durable queue and an idempotent worker for that workload. The simple address expression also cannot establish that a mailbox exists, and a local domain allowlist cannot establish DKIM alignment. Those checks belong to separate components. A low-risk system with no evidence obligation may reasonably keep fewer stages. For this healthtech case, the added work earns its place because it answers an actual audit question without storing message content.
Test negative paths, not just the receipt
A successful transport receipt is weak evidence on its own. It shows that one boundary accepted the request; it does not establish inbox placement, user access, or a successful password change. Record claims at their proper scope.
Acceptance is not delivery.
I would keep a table-driven test with at least nine cases: non-object body, absent field, malformed address, unexpected reset origin, expired timestamp, denied sender domain, renderer exception, transport rejection, and accepted transport. Each failure should assert both the stage code and that later dependencies were not called. The accepted case should assert that the secret-bearing URL appears in the outgoing message but nowhere in the audit record.
Keep the renderer deterministic by injecting the current time. Test HTML escaping if any user-controlled value reaches the template. Here the visible text is fixed, but the URL still needs construction under application control rather than concatenation from arbitrary request data.
Short expiry changes the operational test. Queue delay can consume useful lifetime even when every component behaves correctly, so measure age at transport submission and reject an already expired artifact before sending. Three timestamps are usually enough for diagnosis: issuance, submission, and final redemption. Use a consistent time basis and retain only what policy permits.
What should the audit trail prove?
The useful record answers narrow questions: which policy version evaluated the request, which stage accepted or rejected it, when that happened, and which pseudonymous correlation identifiers connect the application attempt to a transport receipt. It should not become a second database of email content and credentials.
A practical event can contain auditId, event time, template version, sender domain, a recipient pseudonym, expiry time, outcome stage, reason code, and transport ID when one exists. Define retention and access around the compliance obligation that motivated the record. Hashing an email address is not automatically anonymization; predictable addresses can be guessed and compared, so use a keyed construction when stable correlation is required and protect its key separately.
TOTP is sometimes mentioned around short-lived authentication, but RFC 6238 specifies a time-based one-time-password algorithm built from HOTP and a shared secret. A password-reset URL is a different artifact. Borrowing a 30-second TOTP time step or its validation language does not define a reset link's lifetime, storage model, or single-use behavior. Specify those properties in the reset-token design and test them directly.
The decision rule is plain: copy this boundary design only if it improves evidence without retaining sensitive message data. Before adopting it, measure validation failures by stage, render latency, queue age at submission, transport acceptance, reset completion before expiry, and duplicate-use rejection. Those numbers expose where the flow actually loses users.
Top comments (0)