Short answer: treat a password reset email as a security record with two renderings, not as a pretty HTML fragment. Generate one short-lived, single-use link, send equivalent plain text, preserve a recognizable brand without copying secrets into the message, and retain evidence that bounces and invalid recipients were suppressed.
I learned this while reviewing a property-management tenant portal. A reset campaign looked healthy in the provider dashboard, yet several residents never received it because old lease contacts were still eligible. The useful incident number was 550, not an open rate: the SMTP 5.1.1 responses let us prove which addresses were invalid, when suppression happened, and that a later reset was not attempted. The lesson was unglamorous: delivery policy belongs beside template code.
The incident timeline that exposed stale recipients
For an audit, the HTML and text bodies are only part of the record. Store a message identifier, template revision, recipient classification, send timestamp, provider response category, and suppression decision. Do not store the reset token itself. A reviewer should be able to connect a request to a bounded event without gaining a credential.
The copy should say what happened, who can act, and what to do when the request was unexpected. “Reset your password” is clearer than a marketing headline. Put the expiry window in ordinary language, but keep it consistent with the server-side token lifetime. NIST’s authenticator guidance is a useful reference for treating recovery as an authentication ceremony, not a casual notification.
No exceptions.
Plain text is the fallback and the accessibility baseline. Keep the URL on its own line, include the service name, and provide a support path that does not ask the recipient to email a token. In HTML, use a real link, sufficient contrast, a descriptive heading, and a logical reading order; avoid images as the only carrier of the call to action.
How should a password reset email template balance HTML and text?
Build both bodies from the same data object, then test the contract rather than hand-editing two strings. A dark-mode stylesheet can adjust colors, but it cannot repair a button whose text exists only in a background image. Brand-safe means recognizable typography and voice within the limits of mail clients, with no tenant name or unit number exposed in a subject line that can appear on a lock screen.
Here is the boundary I use in a Go mailer: rendering is deterministic, token material stays outside logs, and a hard bounce moves the address to suppression before another send is queued.
package resetmail
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"html/template"
"strings"
)
type ResetData struct {
ServiceName string
ResetURL string
ExpiresIn string
}
func tokenFingerprint(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:8])
}
func Render(data ResetData) (string, string, error) {
name := strings.TrimSpace(data.ServiceName)
if name == "" || data.ResetURL == "" || data.ExpiresIn == "" {
return "", "", fmt.Errorf("incomplete reset data")
}
htmlBody := `<h1>Password reset</h1><p>Use the link below to choose a new password for ` + template.HTMLEscapeString(name) + `.</p><p><a href="` + template.HTMLEscapeString(data.ResetURL) + `">Choose a new password</a></p><p>This link expires in ` + template.HTMLEscapeString(data.ExpiresIn) + `.</p>`
textBody := "Password reset\n\nChoose a new password for " + name + ":\n" + data.ResetURL + "\n\nThis link expires in " + data.ExpiresIn + "."
return htmlBody, textBody, nil
}
The snippet intentionally returns content, not a provider-specific API call. Add fmt to the import list in production, and test the compiler path; the important design point is that a renderer should fail closed on missing data. Preview fixtures should include a long service name, a right-to-left display name, a revoked link, and a mailbox that forces dark mode. I am not sure any preview tool can model every client quirk, so real-device sampling remains part of release evidence.
Buy or build the preview path for this email?
| Choice | Helps | Costs or boundary |
|---|---|---|
| Managed mail transport | Feedback categories and delivery telemetry arrive quickly | Data retention, regional routing, and account-level policy need review |
| Self-hosted SMTP | Direct control over queues and logs | Reputation management and 24/7 response become your team’s work |
| Provider preview API | Repeatable snapshots for HTML and text | A snapshot is not proof of inbox placement or screen-reader behavior |
| In-house preview harness | Fixtures can encode your tenant and compliance cases | Client coverage takes sustained maintenance |
The catch is that a reset workflow is not suitable for silent retries after a permanent failure. Stick with suppression when the response is a hard bounce, and require an explicit address change or verified support process. Temporary 4xx responses can be retried with a bounded queue and an SLO; they should not erase the original evidence. Track request-to-send latency, bounce classification delay, suppression lag, and the percentage of messages with both MIME alternatives.
Set suppression SLOs and retention rules
Make the gate produce an artifact: rendered HTML, plain text, headers, accessibility assertions, fixture identifiers, and the decision log hash. Run it before deployment and again when a template revision changes. In one review, a fixture used a valid tenant address but omitted the stale-contact case; the visual snapshot passed while the suppression path had no evidence at all. We added a permanent-failure fixture, a revoked-link fixture, and a lock-screen subject check, then required those artifacts in the change record. A useful check rejects a missing text part, a link whose host is outside the approved reset domain, a token that appears in logs, or a button label that says only “click here.”
That is the gate.
For the property portal, the operational rule is simple: accept a reset request, issue one expiring token, send one message, classify the response, and suppress an address on a permanent invalid-recipient result. The same event record supports an incident review and a compliance review. It also gives the on-call engineer a concrete next action instead of a dashboard mystery.
This approach does not optimize for maximum creative freedom. It optimizes for a defensible path through hostile mail clients, stale tenant data, and auditors who ask for the exact sequence. Your mileage may vary when regulations require longer retention or a separate identity-proofing step; adjust the evidence schema, not the security properties of the reset link.
Top comments (0)