Email verification
Prove the address is real before the account is worth anything.
Authentication · 5 placeholders
The job
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.
How this one works
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.
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
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.
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.
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.
Verify your email for {{PRODUCT_NAME}}<!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.            </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;"> </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>How it works
One send, start to finish. Every message is in your logs with its delivery events.
Somebody signs up
Your form writes the account as unverified and mints a single-use token.
You send this
One call to POST /v1/emails with the html and the text together.
They click or type
The link and the code both resolve to the same token on your server.
You mark it verified
Then delete the token, so a forwarded email cannot be replayed.
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',
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);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.
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