# Password reset

A password reset email template: one single-use link, an expiry, the request details, table-based HTML for Outlook, a text alternative, and the send call in TypeScript, Python, and curl.

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.

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.

Topics: password reset, forgot password, authentication, account security.

## 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.

## What you need

- **An emails.sh API key**: Created in the dashboard, starting with esh_. It stays on your server.
- **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.

## Subject

```text
Reset your {{PRODUCT_NAME}} password
```

## HTML body

```html
<!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.&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;&#8199;&#65279;</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;">&nbsp;</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>
```

## Plain text body

```text
Reset your password

Somebody asked to reset the password on your {{PRODUCT_NAME}} account.
The link works once and expires in {{EXPIRY_MINUTES}} minutes.

{{RESET_URL}}

The request came from {{REQUEST_IP}} on {{REQUEST_TIME}}.

If this was not you, your password has not changed and there is nothing to
do. If it keeps happening, write to {{SUPPORT_EMAIL}}.
```

## Placeholders

| Placeholder | What it is | Example |
| --- | --- | --- |
| {{PRODUCT_NAME}} | Your product, as the reader knows it. | Acme |
| {{RESET_URL}} | The reset link. Single use, expires on the first successful reset. | https://acme.com/reset?token=b41f0c |
| {{EXPIRY_MINUTES}} | How long the link stays good. | 60 |
| {{REQUEST_TIME}} | When the reset was asked for, with the zone spelled out. | 31 July 2026 at 09:12 UTC |
| {{REQUEST_IP}} | The address that asked. Shown so the reader can check it. | 203.0.113.42 |
| {{SUPPORT_EMAIL}} | Where somebody reports a reset they did not request. | support@acme.com |

## How it works

1. **Somebody asks** The forgot-password form answers the same way whether or not the account exists.
2. **You mint one token** Single use, hashed, with the request time and IP recorded beside it.
3. **You send this** One link, no pixel, no tracking redirect wrapped around the credential.
4. **They set a password** You expire the token, end other sessions, and tell them it changed.

## Code

### TypeScript

`npm install @emails.sh/sdk`

```
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);
```

### Python

`pip install emailssh`

```
import os

from emailssh import Emailssh

mail = Emailssh(api_key=os.environ["EMAILSSH_API_KEY"])

# What changes per send. In your app these come from the row you just
# wrote, not from literals.
values = {
    "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",
}


def fill(body: str) -> str:
    for name, value in values.items():
        body = body.replace("{{" + name + "}}", value)
    return body


with open("email.html", encoding="utf-8") as f:
    html = f.read()
with open("email.txt", encoding="utf-8") as f:
    text = f.read()

sent = mail.send(
    # from_ rather than from, which is a Python keyword.
    from_="Acme <onboarding@emails.sh>",
    to=["ada@example.com"],
    subject=fill("Reset your {{PRODUCT_NAME}} password"),
    html=fill(html),
    text=fill(text),
    tags={"type": "password-reset"},
)

# Queued, not delivered. Ask for the id later, or take the webhook.
print(sent["id"])
```

### curl

`Anywhere with curl and jq`

```
export EMAILSSH_API_KEY=esh_live_...

# The API substitutes nothing, so fill the placeholders before you post.
fill() {
  sed \
      -e 's|{{PRODUCT_NAME}}|Acme|g' \
      -e 's|{{RESET_URL}}|https://acme.com/reset?token=b41f0c|g' \
      -e 's|{{EXPIRY_MINUTES}}|60|g' \
      -e 's|{{REQUEST_TIME}}|31 July 2026 at 09:12 UTC|g' \
      -e 's|{{REQUEST_IP}}|203.0.113.42|g' \
      -e 's|{{SUPPORT_EMAIL}}|support@acme.com|g' "$1"
}

curl -sS https://emails.sh/v1/emails \
  -H "Authorization: Bearer $EMAILSSH_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq -n \
      --arg subject 'Reset your Acme password' \
      --arg html "$(fill email.html)" \
      --arg text "$(fill email.txt)" \
      '{from: "Acme <onboarding@emails.sh>",
        to: ["ada@example.com"],
        subject: $subject,
        html: $html,
        text: $text,
        tags: {type: "password-reset"}}')"
```

## 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.

Docs: https://emails.sh/docs.md
All templates: https://emails.sh/templates.md