Debugging 4 Password Reset Email Failures (Requests, Domains, Templates, Payloads)
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 t
Comments
No comments yet. Start the discussion.