Password reset
One link, short lived, with enough context to spot a reset nobody asked for.
Authentication · 6 placeholders
The job
A reset email is a credential in an inbox. Everything about it follows from that: one action, a short expiry, no tracking pixel, and a clear line about what to do if the request was not yours.
How this one works
The detail that earns its place is the request context. Showing the time and the IP that asked turns a phishing-shaped email into one the reader can check, and it is the line that generates the support ticket you want rather than the account takeover you do not.
What you need
A key, a domain, and the two or three things only your app knows. Nothing here takes longer than the email did to read.
When it sends
When somebody submits the forgot-password form for an address you have on file. Send the same response whether or not the account exists, so the form cannot be used to enumerate users.
A sending domain
Verified with three DNS records, or send from onboarding@emails.sh until yours is ready.
A single-use reset token
Hashed at rest, expired on use, and invalidated when a new one is issued.
The request details
The time and the address that asked, so the reader can tell whether it was them.
The email
Table layout, inline styles, no stylesheet, no web font, no JavaScript. It renders the same in Outlook, Gmail, and Apple Mail, and the plain-text version goes with it every time.
Reset your {{PRODUCT_NAME}} password<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<!-- Stops iOS Mail resizing the text on its own. -->
<meta name="x-apple-disable-message-reformatting">
<title>Reset your password</title>
</head>
<body style="margin:0;padding:0;width:100%;background-color:#f4f4f5;">
<div style="display:none;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#f4f4f5;">The link is good for {{EXPIRY_MINUTES}} minutes.            </div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background-color:#f4f4f5;">
<tr><td align="center" style="padding:24px 12px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:100%;background-color:#ffffff;border:1px solid #e4e4e7;">
<tr><td style="padding:32px;">
<h1 style="margin:0 0 16px;font-family:Helvetica,Arial,sans-serif;font-size:21px;line-height:28px;font-weight:600;color:#18181b;">Reset your password</h1>
<p style="margin:0 0 16px;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:24px;mso-line-height-rule:exactly;color:#3f3f46;">Somebody asked to reset the password on your {{PRODUCT_NAME}} account. Choose a new one here. The link works once and expires in {{EXPIRY_MINUTES}} minutes.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td bgcolor="#18181b" style="background-color:#18181b;"><a href="{{RESET_URL}}" style="display:inline-block;padding:13px 24px;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;">Choose a new password</a></td></tr></table>
<p style="margin:0 0 12px;font-family:Helvetica,Arial,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#71717a;">Button not working? Paste this into your browser:<br><a href="{{RESET_URL}}" style="color:#3f3f46;text-decoration:underline;word-break:break-all;">{{RESET_URL}}</a></p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td height="1" style="height:1px;background-color:#e4e4e7;font-size:0;line-height:0;"> </td></tr></table>
<p style="margin:0 0 16px;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:24px;mso-line-height-rule:exactly;color:#3f3f46;">The request came from:</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;border-top:1px solid #e4e4e7;"><tr><td style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#71717a;" width="45%">Time</td><td align="right" style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#18181b;font-weight:600;">{{REQUEST_TIME}}</td></tr><tr><td style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#71717a;" width="45%">IP address</td><td align="right" style="padding:10px 0;border-bottom:1px solid #e4e4e7;font-family:Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;color:#18181b;font-weight:600;">{{REQUEST_IP}}</td></tr></table>
<p style="margin:0 0 12px;font-family:Helvetica,Arial,sans-serif;font-size:13px;line-height:20px;mso-line-height-rule:exactly;color:#71717a;">If this was not you, your password has not changed and there is nothing to do. If it keeps happening, write to <a href="mailto:{{SUPPORT_EMAIL}}" style="color:#18181b;text-decoration:underline;">{{SUPPORT_EMAIL}}</a> and we will look at the account.</p>
</td></tr>
</table>
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:100%;">
<tr><td style="padding:20px 32px 0;">
<p style="margin:0 0 8px;font-family:Helvetica,Arial,sans-serif;font-size:12px;line-height:18px;color:#71717a;">{{PRODUCT_NAME}} sends this only when a reset is requested.</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>How it works
One send, start to finish. Every message is in your logs with its delivery events.
Somebody asks
The forgot-password form answers the same way whether or not the account exists.
You mint one token
Single use, hashed, with the request time and IP recorded beside it.
You send this
One link, no pixel, no tracking redirect wrapped around the credential.
They set a password
You expire the token, end other sessions, and tell them it changed.
Code
The same send three ways. Each one fills the placeholders, reads the two bodies, and posts them to POST /v1/emails.
import { readFileSync } from 'node:fs';
import { Emailssh } from '@emails.sh/sdk';
const mail = new Emailssh({ apiKey: process.env.EMAILSSH_API_KEY });
// What changes per send. In your app these come from the row you just
// wrote, not from literals.
const vars: Record<string, string> = {
PRODUCT_NAME: 'Acme',
RESET_URL: 'https://acme.com/reset?token=b41f0c',
EXPIRY_MINUTES: '60',
REQUEST_TIME: '31 July 2026 at 09:12 UTC',
REQUEST_IP: '203.0.113.42',
SUPPORT_EMAIL: 'support@acme.com',
};
const fill = (body: string) =>
Object.entries(vars).reduce(
(out, [name, value]) => out.replaceAll('{{' + name + '}}', value),
body
);
const { id } = await mail.send({
from: 'Acme <onboarding@emails.sh>',
to: ['ada@example.com'],
subject: fill('Reset your {{PRODUCT_NAME}} password'),
html: fill(readFileSync('email.html', 'utf8')),
text: fill(readFileSync('email.txt', 'utf8')),
tags: { type: 'password-reset' }
});
// Queued, not delivered. Ask for the id later, or take the webhook.
console.log(id);Questions
Should the email say whether the account exists?
No. Send the same page and the same wording either way. If the address has no account, either send nothing or send a short note saying no account was found, and never let the response time differ enough to answer the question for an attacker.
Why no open tracking on this one?
A tracking pixel on a security email tells you nothing useful and gives a forwarded message a beacon. Delivery events from the API already answer whether it arrived.
What about the confirmation afterwards?
Send a second, separate email once the password actually changes, with the same time and IP block. That one is what catches a takeover, because it arrives even when the reset email was intercepted.
Send this one in the next five minutes.
Before your domain is verified you can send from onboarding@emails.sh, so the first send needs a key and nothing else. Verification is three DNS records and one screen.
Every message has delivery events you can read: accepted, delivered, bounced, complained. No support ticket to find out whether it arrived.
More authentication templates