A password reset email is not complete when an API accepts a request. It is complete when the system can connect one reset attempt to a validated payload, a rendered message, an authorized sender, and a terminal delivery event. Start at the first boundary that cannot produce evidence, then move forward exactly one stage. Do not retry the whole pipeline while the failure class is still unknown.
Short answer: validate and serialize a typed request before the send call, render the template before entering the delivery queue, treat the From domain as deployment configuration, and record a stable attempt ID at every boundary. That order separates a malformed request from a template render error or sender-domain rejection without turning retries into duplicate password reset emails.
I've been paged by missed jobs and duplicate deliveries. The painful invariant is that an accepted job and a delivered message are different facts. A queue receipt proves only that a worker may run; an API response proves only what that API says it proves. For a customer-support workflow, including a compliance notice or account-recovery message, the audit record has to preserve those distinctions.
How should you debug a malformed password reset email API JSON payload?
Follow the message through four gates: request construction, template rendering, sender authorization, and delivery. Keep the same attempt ID throughout. If request construction has no success event, stop there. If rendering fails, no delivery request should exist. If the sender is not authorized, changing template data is noise. If the provider accepts the message but the terminal event never arrives, rebuilding the JSON body is equally unhelpful.
| Gate | Evidence to retain | Failure means | Next check |
|---|---|---|---|
| Request | Attempt ID, schema version, recipient reference, validation result | The JSON contract was not satisfied | Types, required fields, encoding |
| Render | Template version, data keys, render result, content digest | Data and template did not combine cleanly | Missing keys and escaping |
| Sender | From address, configured domain, authorization result | The sending identity is not usable | Deployment configuration and DNS |
| Delivery | Provider message ID and normalized terminal state | The request left the app, but outcome is separate | Event ingestion and state transition |
This ordering matters because several errors can look like “email failed” in an application log. Consider the timeline an on-call engineer should be able to reconstruct from one attempt: the application records requested, validation rejects a missing From address, and processing stops without a queue entry. That is a request failure. On a different attempt, validation succeeds but the chosen template cannot render because its reset URL is absent; the record ends at validated, with the template version and a safe error category attached. That is a render failure. A third attempt reaches the sender check, where the configured identity is rejected; it has rendered content but no submission evidence. Only a fourth attempt reaches the delivery interface and receives an external message identifier. If its later outcome is unknown, the question belongs to event ingestion, not JSON construction. This timeline keeps the investigation bounded: query one attempt ID, find its last valid transition, inspect the input to the next transition, and do not touch downstream configuration until that boundary is understood. A hand-built JSON string can be malformed before the request crosses the network, while a syntactically valid payload can still omit required data. One alert label should not flatten those conditions.
Stop at the gap.
The API JSON payload should contain the minimum data required to address and render the message, plus identifiers used for idempotency and audit correlation. It should not contain the raw reset secret in logs. Store a recipient reference or a redacted address in operational events, and put the attempt ID in structured fields rather than hoping somebody can recover it from a free-form log line later.
No token logging.
This is also how I would approach a Node.js caller even though the preventative example below is in Go: use the runtime's real serializer, check the response contract, and preserve the correlation value. Don't concatenate braces, quotes, or user data into JSON. The language changes; the boundary does not.
Make invalid states fail before the queue
The safest delivery worker receives a command that has already passed structural validation. It should not discover that from is empty after claiming a queue item, and it should never enqueue a partially rendered body. In Go, a small typed command and a constructor create a narrow choke point for those checks.
package resetmail
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"html/template"
"net/mail"
"net/url"
"strings"
)
type Command struct {
AttemptID string `json:"attempt_id"`
To string `json:"to"`
From string `json:"from"`
TemplateVersion string `json:"template_version"`
ResetURL string `json:"reset_url"`
}
type Prepared struct {
Payload []byte
ContentDigest string
}
func Prepare(cmd Command, source string) (Prepared, error) {
if strings.TrimSpace(cmd.AttemptID) == "" {
return Prepared{}, errors.New("missing attempt_id")
}
if _, err := mail.ParseAddress(cmd.To); err != nil {
return Prepared{}, errors.New("invalid recipient address")
}
if _, err := mail.ParseAddress(cmd.From); err != nil {
return Prepared{}, errors.New("invalid from address")
}
resetURL, err := url.ParseRequestURI(cmd.ResetURL)
if err != nil || resetURL.Scheme != "https" || resetURL.Host == "" {
return Prepared{}, errors.New("invalid reset URL")
}
tmpl, err := template.New("reset").Option("missingkey=error").Parse(source)
if err != nil {
return Prepared{}, errors.New("template parse failed")
}
var rendered bytes.Buffer
if err := tmpl.Execute(&rendered, cmd); err != nil {
return Prepared{}, errors.New("template render failed")
}
payload, err := json.Marshal(struct {
AttemptID string `json:"attempt_id"`
To string `json:"to"`
From string `json:"from"`
HTML string `json:"html"`
}{cmd.AttemptID, cmd.To, cmd.From, rendered.String()})
if err != nil {
return Prepared{}, errors.New("payload serialization failed")
}
digest := sha256.Sum256(rendered.Bytes())
return Prepared{Payload: payload, ContentDigest: hex.EncodeToString(digest[:])}, nil
}
Notice what is absent: no manual JSON formatting, no network call before rendering, and no retry loop. The function returns a payload only after every local precondition succeeds. The content digest lets an audit event identify what was prepared without storing the message body or reset link in routine logs.
There is one more deployment rule. Parse templates and exercise representative data in CI, then repeat the check when loading the exact production template artifact. A test fixture should cover a valid reset link, missing data, characters that require HTML escaping, and an invalid sender address. Production still performs the checks because configuration can differ from a build fixture.
Fail closed.
Why the From domain is a separate control plane
An invalid from domain result is not a JSON quoting problem if the server has already decoded the address. Treat it as a sender-identity configuration problem. Confirm that the full From address is syntactically valid, that the intended environment uses the intended domain, and that the domain's authorization records match the system actually sending the message. Keep this configuration out of per-user input.
DKIM provides a domain-level signature mechanism for email. RFC 6376 defines a DKIM-Signature header, identifies the signing domain with the d= tag, and describes public-key retrieval through DNS. That makes signing evidence useful, but it does not turn an application-level “send accepted” event into proof of inbox delivery. Preserve the signing-domain configuration revision alongside the delivery attempt so an operator can answer which identity policy applied at send time.
Rotation deserves the same care as code deployment. Change sender-domain configuration through a reviewed release, verify it before shifting traffic, and retain the previous configuration long enough to explain messages already in flight. Avoid a runbook that says “retry with another From address.” That destroys the relationship between the requested identity and the audited attempt, and it can conceal a configuration regression behind a successful resend.
The template belongs in its own control plane too. Version it. A support agent needs to distinguish “attempt A used template T and was rendered successfully” from “the current template looks correct.” Mutable templates without versioned evidence make incident reconstruction guesswork.
Build an audit record, not a pile of logs
Use an append-only sequence of state changes keyed by the attempt ID. A useful sequence is requested, validated, rendered, submitted, and then a normalized terminal outcome. Record timestamps, the actor or service that caused the transition, the template version, a content digest, and any external message identifier. Never record the reset token itself.
The state machine must reject backward transitions and duplicate side effects. If a worker receives the same attempt twice, it should read the existing state before sending. If the state is already submitted, the worker observes rather than resends. If it stopped after rendered, it may submit the exact prepared artifact under the same idempotency key. The database write that grants permission to send needs a uniqueness constraint on that key; an in-memory check is not enough across workers.
Be precise about evidence. submitted means the delivery interface accepted the request under its documented contract. It does not mean delivered, read, or acted upon. A terminal event must be authenticated, deduplicated, and associated with the existing attempt before it changes the audit state. Unknown event types go to a review path instead of being coerced into success or failure. During a duplicate-delivery page, this distinction is the first thing I want from the runbook: show whether two submissions exist, or whether one submission produced two observations of the same external event. Those cases share a user-visible symptom but have different containment actions. The first calls for disabling new claims on the affected idempotency key and inspecting the permission-to-send transaction. The second calls for deduplicating event ingestion while leaving the original delivery alone. Retrying either case before establishing which timeline exists can create the very second submission the investigation is trying to explain.
For compliance notices, retention and access policy may require more than this operational record. I'm not sure a universal retention period exists for your jurisdiction or policy; legal and security owners have to define it. What engineering can provide is a record whose fields have stable meanings and whose sensitive values are deliberately excluded.
When should you use a different recovery path?
This design is not suitable when email is not an approved recovery channel, when the account has no verified email address, or when policy requires stronger proof before changing credentials. In those cases, stop the email workflow and use the recovery process approved for that risk tier. Do not quietly fall back to a less controlled channel.
TOTP is also not a drop-in delivery substitute. RFC 6238 defines a time-based one-time password algorithm based on a shared secret and a time-step value; adopting it changes enrollment, secret protection, clock handling, and recovery responsibilities. Use it only as part of an authentication design that owns those controls, not as a quick patch for a malformed email request.
The catch is operational cost. Versioned templates, an event state machine, idempotency storage, and restricted audit access add moving parts. A small internal tool with no account-recovery or compliance consequence may reasonably use a simpler synchronous path, provided it still uses typed serialization and does not log secrets. Once missed or duplicate messages can lock out users, create disclosure risk, or leave support unable to prove what happened, the extra boundaries earn their keep.
The decision rule is plain: repair the earliest failed boundary, preserve its evidence, and advance only after it passes. That keeps malformed requests, domain configuration, rendering, and delivery outcomes separate under pressure.
Top comments (0)