# Email verification

A verification email template with a link and a six-digit code, table-based HTML that renders in Outlook, a plain-text alternative, and the send call in TypeScript, Python, and curl.

This is the first email your product ever sends, and it is the one most likely to decide whether somebody finishes signing up. It has to arrive in seconds, survive a corporate link rewriter, and read as the thing the person was just doing.

It carries both a link and a code. The link is what most people click; the code is what saves the person whose webmail opens links in a browser that is not signed in, which is the single most common way a verification flow dead-ends.

Topics: verification, signup, authentication, double opt-in.

## When it sends

Immediately after the signup form is submitted, before the account can do anything that matters. One send per attempt, with a fresh token, and a resend that invalidates the previous one.

## 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 token you can expire**: Single use, short lived, and stored hashed. The URL in this email is a credential.
- **A page that consumes it**: One route that accepts the token, marks the address verified, and signs the person in.

## Subject

```text
Verify your email for {{PRODUCT_NAME}}
```

## 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>Verify your email</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;">Your code is {{VERIFY_CODE}}, 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;">Verify your email address</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;">Confirm this address to finish setting up your {{PRODUCT_NAME}} account. The link is good for {{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="{{VERIFY_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;">Verify email address</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="{{VERIFY_URL}}" style="color:#3f3f46;text-decoration:underline;word-break:break-all;">{{VERIFY_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;">Or type this code into the signup page instead:</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td bgcolor="#f4f4f5" style="background-color:#f4f4f5;border:1px solid #e4e4e7;padding:16px;text-align:center;font-family:Consolas,Menlo,Courier,monospace;font-size:26px;line-height:32px;letter-spacing:6px;color:#18181b;">{{VERIFY_CODE}}</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 you did not create a {{PRODUCT_NAME}} account, ignore this email and nothing further happens. Questions go to <a href="mailto:{{SUPPORT_EMAIL}}" style="color:#18181b;text-decoration:underline;">{{SUPPORT_EMAIL}}</a>.</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}} sent this because someone signed up with this address.</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
```

## Plain text body

```text
Verify your email address

Confirm this address to finish setting up your {{PRODUCT_NAME}} account.
The link is good for {{EXPIRY_MINUTES}} minutes.

{{VERIFY_URL}}

Or type this code into the signup page instead: {{VERIFY_CODE}}

If you did not create a {{PRODUCT_NAME}} account, ignore this email and
nothing further happens. Questions go to {{SUPPORT_EMAIL}}.
```

## Placeholders

| Placeholder | What it is | Example |
| --- | --- | --- |
| {{PRODUCT_NAME}} | Your product, as the reader knows it. | Acme |
| {{VERIFY_URL}} | The link that consumes the token. Single use. | https://acme.com/verify?token=8f3c1a |
| {{VERIFY_CODE}} | The same token as something typeable. | 481902 |
| {{EXPIRY_MINUTES}} | How long the link and the code stay good. | 30 |
| {{SUPPORT_EMAIL}} | Where a confused reader writes. A real mailbox. | support@acme.com |

## How it works

1. **Somebody signs up** Your form writes the account as unverified and mints a single-use token.
2. **You send this** One call to POST /v1/emails with the html and the text together.
3. **They click or type** The link and the code both resolve to the same token on your server.
4. **You mark it verified** Then delete the token, so a forwarded email cannot be replayed.

## 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',
  VERIFY_URL: 'https://acme.com/verify?token=8f3c1a',
  VERIFY_CODE: '481902',
  EXPIRY_MINUTES: '30',
  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('Verify your email for {{PRODUCT_NAME}}'),
  html: fill(readFileSync('email.html', 'utf8')),
  text: fill(readFileSync('email.txt', 'utf8')),
  tags: { type: 'email-verification' }
});

// 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",
    "VERIFY_URL": "https://acme.com/verify?token=8f3c1a",
    "VERIFY_CODE": "481902",
    "EXPIRY_MINUTES": "30",
    "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("Verify your email for {{PRODUCT_NAME}}"),
    html=fill(html),
    text=fill(text),
    tags={"type": "email-verification"},
)

# 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|{{VERIFY_URL}}|https://acme.com/verify?token=8f3c1a|g' \
      -e 's|{{VERIFY_CODE}}|481902|g' \
      -e 's|{{EXPIRY_MINUTES}}|30|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 'Verify your email for Acme' \
      --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: "email-verification"}}')"
```

## Questions

### Why send a code as well as a link?

Because links break in ways you do not control. A link opened in a webmail preview browser lands in a session that is not signed in, and corporate scanners follow links before the person does, which can consume a single-use token before they ever click. A code is typed into the tab they already have open.

### How long should the token last?

Thirty minutes covers somebody who gets distracted, and it is short enough that a leaked mailbox archive is not a standing key to the account. Make the resend cheap instead of the expiry long.

### Does this need an unsubscribe link?

No. It answers a form the person just submitted and it is not marketing. Adding one would let a reader opt out of the mail that lets them sign in. The digest template is where an unsubscribe belongs.

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