DEV Community

NorbertChristensen3183
NorbertChristensen3183

Posted on

Password Reset Email Contracts: Invalid Domains and Malformed JSON Rendering

TL;DR: Treat a short-lived password-recovery email as one versioned contract owned by the application, even when another system performs delivery. Validate the sender domain before accepting work, render before creating a durable send attempt, and record a redacted audit trail containing the contract version and outcome. This ordering separates malformed JSON, an invalid From domain, and a template render failure instead of collapsing them into one useless API error.

For an edtech platform, the reset message sits on an awkward boundary: a learner needs it quickly, but it can change access to an account. The useful debugging question is not merely whether an email API accepted a payload. It is which component owned each invariant when the request crossed the boundary.

How should a malformed password reset email request be diagnosed?

Template ownership determines where correctness can be proved. When the application owns a versioned subject and body schema, it can validate required fields, render with fixed test data, and record exactly which version produced an attempt. The delivery adapter then has a narrow role: accept rendered content and transport metadata, submit it, and return a stable disposition.

Remote template ownership can also be valid. It lets editorial changes ship independently of application releases, but a template identifier may remain unchanged while required variables change, or an operator may revise expiry wording without a corresponding code review. For this security-sensitive flow, I would choose reproducibility over independent editing. The application owns semantic content; the transport owns mechanical concerns such as queueing and DKIM signing.

This choice has a real limitation. Application-owned rendering is a poor fit when a regulated communications team must publish urgent copy independently, translations change much faster than backend releases, or non-engineers are accountable for approval. In those conditions, a remotely governed template registry with immutable versions and a tested variable schema is the better ownership model. It adds a deployment boundary and demands stronger contract testing, but it preserves the organization's actual approval path. The decision is between reproducibility and editing autonomy, not between correct and incorrect tooling.

One boundary, one owner.

RFC 6376 defines DKIM as domain-level message signing. It does not validate application JSON or prove that a recovery URL rendered correctly. Conflating those controls produces misleading evidence.

What belongs in the delivery contract?

Start with a typed command rather than a transport-shaped map. It needs a command ID, idempotency key, template version, sender, recipient, reset URL, and absolute expiry instant. An instant is preferable to vague copy such as "soon": tests can freeze time, and rendering can use one definition of expiry.

The secret URL should not appear in ordinary logs. Retain the command ID, template version, sender domain, a recipient correlation hash, expiry, render result, attempt number, and final disposition. Retention and access are organization-specific compliance decisions; neither cited RFC supplies those limits.

package resetmail

import (
    "encoding/json"
    "errors"
    "fmt"
    "net/mail"
    "strings"
    "time"
)

type Command struct {
    CommandID string `json:"command_id"`
    IdempotencyKey string `json:"idempotency_key"`
    TemplateVersion string `json:"template_version"`
    From string `json:"from"`
    To string `json:"to"`
    ResetURL string `json:"reset_url"`
    ExpiresAt time.Time `json:"expires_at"`
}

func Decode(raw []byte, now time.Time, allowed map[string]bool) (Command, error) {
    var cmd Command
    dec := json.NewDecoder(strings.NewReader(string(raw)))
    dec.DisallowUnknownFields()
    if err := dec.Decode(&cmd); err != nil {
        return cmd, fmt.Errorf("decode reset command: %w", err)
    }
    if cmd.CommandID == "" || cmd.IdempotencyKey == "" || cmd.TemplateVersion == "" {
        return cmd, errors.New("missing command identity or template version")
    }
    from, err := mail.ParseAddress(cmd.From)
    if err != nil {
        return cmd, fmt.Errorf("parse from address: %w", err)
    }
    at := strings.LastIndexByte(from.Address, '@')
    if at < 1 || !allowed[strings.ToLower(from.Address[at+1:])] {
        return cmd, errors.New("from domain is not authorized")
    }
    if _, err := mail.ParseAddress(cmd.To); err != nil {
        return cmd, fmt.Errorf("parse recipient: %w", err)
    }
    if !cmd.ExpiresAt.After(now) {
        return cmd, errors.New("reset link is already expired")
    }
    return cmd, nil
}
Enter fullscreen mode Exit fullscreen mode

Strict decoding is deliberate. Accepting template_verison and silently selecting a default would transform a malformed request into a plausible but unauditable send. Rejection creates noise during producer drift, yet it occurs before a learner receives incorrect content.

Do not retry it.

The domain allowlist is an application authorization rule, separate from DKIM. A syntactically valid address can use a domain this application may not present as its sender; a permitted domain still needs the transport's signing and DNS configuration. Diagnose them independently.

Why is rendering a state transition?

A missing field is deterministic for a command and template version. Retrying the same input against the same template wastes capacity and hides the cause, so render before enqueueing and close failures with bounded codes such as missing_template_field. Only transport failures belong in the delivery retry policy.

Expiry wins.

Use an absolute UTC timestamp in the rendered model. Learner-facing copy may add a localized duration, but tests should assert the underlying instant and verify that no send begins after it. RFC 6238 describes a different time-limited authentication mechanism, TOTP, and recommends a default 30-second time step. A reset URL is not a TOTP. Copying that interval would be unsupported; choose reset lifetime from policy and threat modeling, then keep issuance, rendering, and redemption on the same expiry definition.

The exactly-once mindset needs precision. A network cannot prove that a person received or read an email exactly once. The backend can enforce one durable command per idempotency key, one successful render per command and template version, and no fresh submission after recorded acceptance. If a timeout leaves the result unknown, reconcile using the stored attempt identifier before submitting again.

Read failures by layer

Once the contract exists, triage becomes mechanical. Parse errors belong to request validation. Sender authorization belongs to identity configuration. Missing values belong to rendering. Temporary refusal belongs to transport. A single send_failed counter destroys the evidence needed to decide whether retrying is correct.

Failure Owner Retry unchanged? Audit evidence
Unknown JSON field Calling application No command ID, schema version, field error
Sender domain not allowed Identity configuration No domain, policy version
Required render value absent Template release No template version, bounded error code
Transport result unknown Delivery adapter Reconcile first attempt ID, time, disposition

Do not log the raw reset URL for reproducibility. Use a synthetic URL in fixtures and a one-way correlation value in production. Also avoid one boolean named verified for domain authorization and DKIM verification: RFC 6376 describes signing and verification around a signing domain identifier, while an allowlist answers a local authorization question.

Tests should cross boundaries intentionally. Feed the decoder an unknown field and trailing data. Use a well-formed sender from a disallowed domain. Render every supported template version with its smallest complete model, then omit each required value. Advance a fake clock to the expiry instant. Finally, return accepted, rejected, and unknown transport outcomes while reusing one idempotency key. Those cases prove state transitions and their evidence; a happy-path snapshot does not.

Roll out template ownership compactly

Begin with a dual-render comparison using synthetic, non-secret data. Render the application-owned and existing templates, normalize inconsequential whitespace, and compare destination, link host, expiry wording, and version. Send only one result. Record bounded differences, never secrets.

Next, enable the new renderer for a small cohort behind the same idempotency ledger. Observe rejection counts by schema, sender-policy, and template version; addresses and recovery secrets do not belong in metric dimensions. Roll back by selecting the prior renderer, not by deleting commands or attempts.

The final sequence is modest: validate, authorize, render, persist, submit, reconcile. Its strength is visible ownership. Malformed JSON cannot masquerade as a delivery outage, an unauthorized From domain cannot be repaired by retries, and absent template data cannot create an accepted send. For a short-lived recovery message, that auditability matters more than a convenient initial API call.

References

Top comments (0)