Reject a password-reset email before delivery whenever its template references a variable that the render context does not contain. The deciding constraint is delivery reliability: retrying a malformed payload cannot repair it, while accepting partial output can turn a valid reset flow into an unusable message. A Node.js service should therefore validate and render synchronously at the boundary, record a stable failure reason, and enqueue only an immutable, complete delivery command.
TL;DR: treat template compilation as input validation, not as a side effect of the email provider. Enforce four invariants: every referenced placeholder is present, every security-sensitive value is non-empty, the rendered message contains the expected verification link, and one reset request produces one auditable delivery intent.
Fail closed.
How should a password reset email handle malformed template variables?
A password-reset endpoint can return success while the actual message contains a literal token such as {{reset_url}}, an empty anchor target, or no usable link at all. From the account system's perspective, the request was accepted; from the reader's perspective, recovery is broken. The split between those two observations is the failure boundary that matters. There are at least three distinct states, and collapsing them under a generic "email failed" status damages diagnosis: template input was invalid before rendering; a valid message was rejected during submission; or a submitted message did not reach the mailbox. Only the middle state can sensibly be retried by a delivery worker without changing its input. A missing variable is deterministic. Retrying it 10 times merely creates 10 audit entries for the same programming error unless the worker silently changes data, which would be worse. The invariant is strict: no delivery intent exists until rendering succeeds. For an account-signup verification link, the same rule prevents a welcome message from being recorded as delivered when the call to action cannot be constructed. Password reset is the running example here because the consequence is immediate, but the boundary belongs in the shared transactional-email path.
Decision record: validate before the queue
The architecture decision is to compile the template against an explicit schema in the request path, then store the rendered artifact or a versioned template reference plus immutable variables in the same durable operation that creates the delivery intent. The queue transports work; it does not decide whether the work is valid.
| Option | Failure boundary | Retry behavior | Audit quality | Decision |
|---|---|---|---|---|
| Validate and render before enqueue | Request is rejected before a delivery intent exists | No retry for deterministic input errors | Records the template version and validation result | Chosen |
| Render inside the worker | Invalid input appears after acceptance | Easy to retry the wrong failure class | Requires correlating request, queue, and render logs | Rejected for reset email |
| Let the delivery API merge variables | Validation semantics sit outside the application | Depends on an external contract | Application may lack the final rendered artifact | Valid only when that external boundary is deliberately authoritative |
This is an exactly-once mindset, not a claim that a networked system performs magic. The application creates one logical delivery intent with an idempotency key derived from the reset challenge identifier and message purpose; workers may execute more than once, but every attempt remains attached to that same intent. The audit record should distinguish validation_failed, ready, submitted, and submission_failed rather than pretending those states are interchangeable. Do not store the reset secret itself in routine logs.
Four values deserve explicit checks: the recipient, template version, reset URL, and expiration presentation required by the selected template. The URL must come from the reset workflow's trusted construction path rather than arbitrary template input. Exact expiry policy is an application security decision, so the renderer should display the value it receives without inventing one.
The critical path in code
Although the surrounding application may be Node.js, the contract should not depend on a JavaScript templating package. This Go example makes the boundary concrete: a strict renderer rejects an absent key, verifies that the security-sensitive value survived rendering, and emits an immutable command only after validation. The same tests can be applied to a Node.js adapter.
package resetmail
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"html/template"
"strings"
)
type Input struct {
ChallengeID string
Recipient string
ResetURL string
ExpiresText string
}
type Command struct {
IdempotencyKey string
Recipient string
Template string
HTML string
}
func Build(in Input) (Command, error) {
if strings.TrimSpace(in.ChallengeID) == "" ||
strings.TrimSpace(in.Recipient) == "" ||
strings.TrimSpace(in.ResetURL) == "" ||
strings.TrimSpace(in.ExpiresText) == "" {
return Command{}, errors.New("reset email input is incomplete")
}
const version = "password-reset-v3"
const source = `<p>Reset your password:</p><p><a href="{{.ResetURL}}">Continue</a></p><p>{{.ExpiresText}}</p>`
tmpl, err := template.New(version).Option("missingkey=error").Parse(source)
if err != nil {
return Command{}, err
}
var rendered bytes.Buffer
if err := tmpl.Execute(&rendered, in); err != nil {
return Command{}, err
}
if !strings.Contains(rendered.String(), in.ResetURL) {
return Command{}, errors.New("rendered reset URL is missing")
}
digest := sha256.Sum256([]byte(in.ChallengeID + "|password-reset"))
return Command{
IdempotencyKey: hex.EncodeToString(digest[:]),
Recipient: in.Recipient,
Template: version,
HTML: rendered.String(),
}, nil
}
The important behavior is missingkey=error; permissive rendering is inappropriate for a credential-recovery message. A unit test should remove each required field in turn and assert that no command is returned. A contract test should also render the production template version with representative escaped characters, inspect the link target, and verify that the plain-text alternative carries the same destination if the system sends multipart mail.
Short tests catch this class of regression earlier than a provider preview because they run against the application's actual mapping from domain data to template data. A preview remains useful for visual inspection, but it must use the same renderer and template version as production or it proves little about the critical path.
Error handling without accidental retries
Map validation failure to a client-visible, stable application error when the caller can correct the payload; when the missing value indicates an internal mapping defect, return a generic failure externally and retain the precise reason in restricted telemetry. The exact HTTP status is less important than consistency between the API contract and retry policy. A caller must never interpret a deterministic template error as an instruction to hammer the endpoint.
Keep the observable dimensions bounded: template version, lifecycle state, failure class, and attempt count are useful; recipient addresses, reset tokens, and full URLs are not suitable metric labels. Correlation belongs to an opaque intent identifier. The audit trail can then answer a narrow question without reconstructing secrets: did validation pass, which version rendered, was a command created, and which submission attempts followed?
Deployment needs one additional guard. Activate a new template version only after its schema and golden render tests pass, and keep in-flight commands pinned to the version recorded when they were created. Otherwise a worker retry can render different content from the first attempt, defeating reconciliation. This costs storage or version retention, but it buys determinism where account access is involved.
No silent fallback.
Substituting an empty string or dropping the link can make a dashboard look healthier while users receive broken mail. A fallback is defensible only if it produces a complete, security-reviewed message with equivalent semantics and is recorded as a distinct template version, not if it masks missing input.
The rejected option and where it still fits
Rendering exclusively inside the asynchronous worker was rejected because the API would acknowledge work whose validity had not yet been established. It also mixes two retry domains: deterministic compilation and transient submission. The resulting worker can be made correct, but its state machine is larger, and the request path cannot truthfully say that it accepted a deliverable reset message.
That design has a valid use case. For bulk, non-urgent editorial mail whose audience data is finalized after scheduling, worker-side rendering can preserve late binding and avoid storing large rendered artifacts. It still needs strict validation, version pinning, a dead-letter policy, and an audit state that separates render rejection from transport failure. Those trade-offs do not fit an individual password-reset or signup-verification link, where a user is waiting and the input set is already known.
The operational decision rule is concise: if a missing variable cannot be repaired without new business data, validate before enqueueing; if delayed enrichment is intentional, model that enrichment as an explicit state rather than calling the payload ready. This keeps template errors out of the transport retry loop and makes reconciliation possible without reading message bodies.
References
- Amazon Simple Email Service documentation: 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)