DEV Community

WilhelmKnight8435
WilhelmKnight8435

Posted on

Duplicate Password Reset Links from Email Retries (A Template Ownership Failure)

TL;DR: Let the identity service own password-reset intent and token issuance, let one versioned template contract own rendering, and let the delivery worker retry the same immutable message command. A bounce or suppression decision must open a support-routing path; it must never create a second reset link.

For a property-management platform, this separation matters twice: a resident needs a usable recovery message, while a contact-form report about a missing message must reach the correct property support queue without exposing the token. The useful debugging question is therefore not merely "did email fail?" It is whether intent, rendering, delivery, and support routing can each be reconstructed from durable identifiers.

Decision and invariants

The architecture decision is to assign template ownership to the identity domain while keeping transport ownership in the mail worker. The identity service publishes a versioned render contract and creates a single reset intent; the worker may render that contract or consume its rendered result, but it cannot reinterpret the request, mint credentials, or silently switch templates.

Four invariants govern the design. One user action creates one logical reset intent. Every delivery attempt refers to the same message ID, intent ID, template version, and non-secret recipient fingerprint. A terminal recipient-policy outcome stops automated delivery attempts for that intent. Finally, contact-form routing uses property and tenancy metadata, not the recovery token or raw template variables. Applicable retention, access-control, and deletion limits still have to be set by the organization's compliance owner.

Keep the distinction.

An audit trail needs events rather than a mutable "email status" field: reset_intent_created, message_rendered, delivery_attempted, delivery_outcome_recorded, and support_case_routed. Store hashes or opaque identifiers where diagnostics do not require personal data, and never log the reset token.

How can password reset email retries cause duplicate links?

A retry produces a different link when the unit being retried is token creation rather than message delivery. A request handler creates a token, calls a sender, receives an ambiguous error, and reruns the whole handler. The second execution has the same business purpose but a new credential and often a new message identity. This is the first place to debug transactional email retries causing duplicate links: separate the command that authorizes recovery from the command that attempts email delivery.

Start triage with a four-key join: intent_id, message_id, template_version, and recipient_fingerprint. If one intent has two message IDs, duplication sits before the transport loop. If one message ID has two template versions, template resolution is mutable. If repeated attempts retain all four keys, investigation moves to recorded recipient-policy and transport outcomes. A bounced recipient may already appear on a suppression list, but that recipient state and the reset intent are different records with different authority: the former decides whether another delivery attempt is allowed, while the latter proves which recovery action the resident requested. The diagnostic ledger should show 1 intent, 1 message identity, 4 join keys, and an attempt sequence that grows without changing the credential-bearing content.

No new token.

For the property support path, add property_id and a coarse resident_role to the routing projection, then discard message content. A contact form saying "my reset email never arrived" can reach the property's resident-support queue from those fields and the delivery outcome. It does not need the link.

Ownership options and failure boundaries

Template ownership Retry determinism Audit consequence Trade-off
Identity domain owns a versioned contract Intent records one version Policy and rendered evidence can be joined Contract releases require coordination
Delivery worker owns templates Transport changes stay local Review must correlate deployment state Faster mail iteration; weaker domain ownership
Each caller owns markup Callers can diverge Evidence is fragmented Local autonomy; highest reconciliation burden

The first option fits because reset wording, variable availability, and token placement are identity-domain policy, while bounce handling and suppression evaluation belong to delivery operations. This deliberately asymmetric division lets the transport team manage attempts without authority to change the semantic message.

Mustache variables are escaped by default, sections render according to their value, and partials compose templates. Those mechanics do not validate domain completeness. Validate required fields before rendering, pin the template version in the command, and treat a missing variable as a permanent render failure rather than a reason to retry transport.

Critical path in Go

The critical path models idempotency at the business boundary. A production transaction must atomically persist the intent and its outbox command so a process exit cannot leave one without the other.

package recovery

import "context"

type Command struct {
    IntentID, MessageID, RecipientFingerprint string
    PropertyID, TemplateVersion                string
    RenderData                                 map[string]string
}

type Outcome struct { Terminal, Suppressed bool; Code string }

type Store interface {
    BeginAttemptOnce(context.Context, string, int) (bool, error)
    RecordOutcome(context.Context, string, int, Outcome) error
}
type Renderer interface { Render(string, map[string]string) (string, error) }
type Transport interface { Send(context.Context, string, string) (Outcome, error) }

func Attempt(ctx context.Context, s Store, r Renderer, t Transport, c Command, n int) error {
    started, err := s.BeginAttemptOnce(ctx, c.MessageID, n)
    if err != nil || !started { return err }
    body, err := r.Render(c.TemplateVersion, c.RenderData)
    if err != nil {
        _ = s.RecordOutcome(ctx, c.MessageID, n, Outcome{Terminal: true, Code: "render_failed"})
        return err
    }
    outcome, sendErr := t.Send(ctx, c.MessageID, body)
    if err := s.RecordOutcome(ctx, c.MessageID, n, outcome); err != nil { return err }
    if outcome.Terminal || outcome.Suppressed { return nil }
    return sendErr
}
Enter fullscreen mode Exit fullscreen mode

MessageID does not change across attempts. Rendering is pinned to TemplateVersion. A terminal or suppressed outcome is a completed delivery decision, not another reset request. The audit log should also record who cleared a recipient restriction, under which approved policy, and which later intent was affected; automatic "unsuppress and resend" logic does not belong in a generic retry loop.

The rejected option is caller-owned markup. I reject it for password recovery because the reconciliation cost is disproportionate: application teams can express the same intent with different variables, escape behavior, and deployment histories. This is a clear trade-off, not a universal prohibition.

Caller ownership remains valid for low-risk notifications whose semantics belong to separate applications, especially when no shared credential is embedded and each caller retains complete audit evidence. For account recovery, centralized contract ownership narrows the authority to place a credential in content and makes a specific template version part of the ledger.

The operational test is concise: replay a recorded command against its pinned template and verify that the same logical content results without minting a token or consulting mutable routing state. Then inject a render failure, an ambiguous transport error, a terminal outcome, and a suppressed outcome. Only the ambiguous non-terminal attempt may be scheduled again; every test must retain one intent ID and one message ID.

References

Top comments (0)