Short answer: validate the event payload and required template variables before rendering, render from a versioned application-owned template, and return a structured 400 response before any email API call when the request is malformed. For a logistics password reset with a short expiry, this keeps user input out of the template itself and makes ownership explicit.
A 400 Bad Request is a boundary result, not a rendering strategy. The useful question is where that boundary lives. If malformed JSON, a missing reset_url, and an unavailable downstream transport all collapse into one generic error, operators can't tell whether to fix the caller, the template release, or delivery.
This architecture decision record chooses application-owned templates for the reset message. The application validates and renders; a transport adapter accepts a complete subject, HTML body, text body, and recipient. The catch is real: the application team now owns escaping, template review, and deployment coordination. A provider-owned template is a valid choice when non-engineers must edit copy independently and the provider's versioning and preview controls meet the same release bar.
What breaks before malformed JSON event notification email APIs return 400?
The first invariant is that parsing, schema validation, rendering, and transport submission are separate stages. JSON syntax errors stop at parsing. Valid JSON with absent or mistyped fields stops at schema validation. A template-variable mismatch stops before transport. Only a fully rendered message reaches the email adapter. This ordering prevents a provider-specific response from becoming the primary validator for an internal event contract.
The second invariant is that the reset token never appears in logs, preview URLs, analytics events, or exception text. OWASP recommends a cryptographically secure, single-use, expiring reset token and a consistent response for existing and nonexistent accounts. For a logistics account, the public message can say that reset instructions were requested; it should not reveal whether a driver, dispatcher, or customer address exists. Short expiry is a policy input, not a hard-coded sentence: pass a display value such as 15 minutes separately from the signed reset URL, while the server remains the authority on actual expiration.
Keep channels distinct. SMS segmentation depends on encoding and character count, so an email HTML template should not be repurposed as an SMS body. Email also needs a plain-text alternative, because HTML rendering is not guaranteed to be the only presentation. Shared event data is fine.
Shared presentation is not.
Failure boundaries should be boring:
- Invalid JSON returns
400with a stable machine-readable problem type. - A missing required variable returns
400and identifies field names, never field values. - An unknown template version is a deployment or configuration error and must not be disguised as bad caller input.
- A syntactically valid message that the transport rejects belongs to the transport stage, with its provider response mapped into the application's own error vocabulary.
That last distinction matters during incident triage. Don't retry a missing reset_url; retrying deterministic client input only creates noise. Transport retry policy belongs after rendering and should account for idempotency, rate limits, and the ambiguity of a timed-out submission. I'm not sure one retry schedule fits every carrier and mailbox provider; delivery telemetry and the transport's documented semantics are what resolve that choice.
Treat the endpoint as a compiler pipeline. Decode bytes into data, validate the event contract, select an immutable template version, render with strict variable lookup and contextual escaping, then hand the finished message to a narrow transport interface. Each stage has one error class and one owner.
The response can follow the application/problem+json format standardized by RFC 9457. A parser failure might use a type such as urn:problem:invalid-json; a contract failure might use urn:problem:invalid-event. The URI should identify documentation for the problem class, while detail describes this occurrence without echoing secrets. Stable types are easier for Node.js, Python, and other callers to branch on than prose.
Do not infer absent variables as empty strings. A blank expiry or missing reset link can produce polished HTML that is operationally useless, which is worse than a clear 400 because the message may still be accepted for delivery. Strict rendering makes the contract visible.
It fails early.
For previewing, use the same render function and template artifact used by the send path, but feed it fixture data in an authenticated development or review environment. A preview must never fetch a live token or send mail. Escape variables according to context, reject unknown or missing keys, disable active content, and place the rendered document in a sandboxed iframe or save it as a review artifact. Even then, a browser preview proves only that the HTML parses and looks plausible in that browser; it does not prove inbox placement or consistent rendering across mail clients.
Template ownership changes the failure surface
Template ownership is a release decision. It determines who can change executable presentation, how that change is reviewed, and whether a code deployment and a copy deployment can drift apart.
| Option | Strong fit | Main cost | Failure boundary |
|---|---|---|---|
| Application-owned, versioned template | Security-sensitive transactional mail whose variables and expiry language change with code | Engineers own rendering, escaping, previews, and copy releases | Build or deploy fails before transport when the contract and template disagree |
| Provider-owned template | Copy changes need an independent editorial workflow | Application and remote template versions can drift; preview and rollback depend on provider controls | Remote template selection and rendering become part of transport integration |
| Dedicated internal rendering service | Several applications need one governed template catalog | Another service, deployment path, and availability dependency | Rendering has its own API and operational boundary |
For this reset flow, application ownership wins because reset_url, expiry_text, locale, and event version evolve together. That is a narrow decision, not a universal preference. Stick with a provider-owned template when the communications team needs to ship copy without an application release and can enforce strict variables, immutable versions, access control, preview, and rollback there. Use an internal rendering service when multiple products genuinely share governance; don't create one for a single template.
The decision also changes testing. An application-owned template can be checked in the same change as its schema: fixtures exercise missing variables, escaping, text alternatives, and representative long names. A remote template needs contract tests against a pinned provider template identifier plus a release process that prevents an editor from deleting a required placeholder. Neither model removes compliance review. Password-reset mail is transactional, but sender identity, retention, audit access, and regional requirements still need named owners.
One critical path, two render targets
The example below shows the boundary, not a complete web framework. It accepts raw request bytes, emits a problem document for caller errors, renders both bodies through an injected strict renderer, and submits only complete content through a generic transport. Token generation and validation belong to the account service; this handler receives a previously constructed reset URL and must not log it.
import json
from dataclasses import dataclass
from typing import Any, Mapping, Protocol
class StrictRenderer(Protocol):
def render(self, template: str, variables: Mapping[str, str]) -> str: ...
class EmailTransport(Protocol):
def send(self, *, recipient: str, subject: str, html: str, text: str) -> None: ...
@dataclass(frozen=True)
class RequestError(Exception):
problem_type: str
detail: str
fields: tuple[str, ...] = ()
REQUIRED = {
"recipient": str,
"reset_url": str,
"expiry_text": str,
}
def parse_event(raw_body: bytes) -> dict[str, Any]:
try:
value = json.loads(raw_body)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RequestError(
"urn:problem:invalid-json",
"The request body is not valid JSON.",
) from exc
if not isinstance(value, dict):
raise RequestError(
"urn:problem:invalid-event",
"The event must be a JSON object.",
)
invalid = tuple(
name for name, expected_type in REQUIRED.items()
if not isinstance(value.get(name), expected_type) or not value[name].strip()
)
if invalid:
raise RequestError(
"urn:problem:invalid-event",
"Required fields are missing or have invalid types.",
invalid,
)
return value
def problem(error: RequestError) -> tuple[int, str, dict[str, Any]]:
document: dict[str, Any] = {
"type": error.problem_type,
"title": "Invalid notification event",
"status": 400,
"detail": error.detail,
}
if error.fields:
document["fields"] = list(error.fields)
return 400, "application/problem+json", document
def send_password_reset(
raw_body: bytes, renderer: StrictRenderer, transport: EmailTransport
) -> None:
event = parse_event(raw_body)
variables = {
"reset_url": event["reset_url"],
"expiry_text": event["expiry_text"],
}
html = renderer.render("password-reset-v3.html", variables)
text = renderer.render("password-reset-v3.txt", variables)
transport.send(
recipient=event["recipient"],
subject="Reset your logistics account password",
html=html,
text=text,
)
A strict renderer must perform HTML escaping for the link attribute and text escaping for visible copy. Don't implement that escaping with string replacement; use a maintained template engine configured for autoescaping and strict undefined-variable behavior. The example's renderer interface makes that policy testable without turning the article into a framework tutorial.
The contract needs negative tests: truncated JSON, a JSON array instead of an object, null, an empty reset_url, a numeric expiry_text, an extra field, and a variable containing HTML metacharacters. Decide explicitly whether extra fields are rejected or ignored. Rejecting them catches caller typos sooner; ignoring them eases additive schema evolution. Your mileage may vary, but the choice must be encoded in the schema and compatibility policy rather than left to the template engine.
Observe stages, not secrets. Useful counters include parse failures by problem type, validation failures by field name, render failures by template version, transport acceptance, and eventual delivery outcomes. Keep recipient addresses, tokens, reset URLs, and rendered bodies out of labels and logs. Correlate with an opaque notification ID.
The remote-template boundary remains useful
Provider-side rendering is rejected for this particular password-reset path because the application contract and security copy should ship as one reviewed version. Sending a template identifier plus arbitrary variables across the transport boundary adds a second contract that can drift, and an HTML preview generated from a different artifact cannot prove what the send path will render.
It still has a valid use case. A mature communications team may need localized copy releases on a separate cadence, backed by provider access controls, immutable template versions, approval, preview, and rollback. In that environment, remote ownership can reduce engineering coordination. It is not suitable when required-variable enforcement is weak, versions are mutable, or production previews require live reset data.
Do not let the rejected option turn into a permanent abstraction leak. Keep the application-facing command stable: recipient, notification kind, locale, template version, and typed data enter; a delivery result returns. The adapter may render locally, call an internal renderer, or invoke a provider template, but callers should not depend on vendor placeholder syntax. This also makes a later ownership change possible without rewriting every event producer.
The decision record should be reopened when the editorial release cadence, localization count, compliance workflow, or number of producing applications changes. Until then, one versioned contract and one rendering path are easier to audit.
References
- https://www.rfc-editor.org/rfc/rfc8259
- https://www.rfc-editor.org/rfc/rfc9457
- https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#sandbox
- https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
- https://www.twilio.com/docs/glossary/what-sms-character-limit
Top comments (0)