Team invite
Who invited them, to what, as what, and for how long the link lasts.
Onboarding · 7 placeholders
The job
An invite arrives from a product the reader may never have heard of, about a workspace they cannot see, sent by someone whose name is the only familiar thing in the message. Put that name in the subject line and the first sentence, because it is what makes this mail legitimate rather than spam.
How this one works
Setting reply_to to the inviter is the other half. A person who is unsure whether to accept replies to the email, and that reply should reach the colleague who invited them rather than your support queue.
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 a member with permission to invite adds an address. If the address already has an account, the same email still applies; the accept link skips signup.
A sending domain
Verified with three DNS records, or send from onboarding@emails.sh until yours is ready.
An expiring invite token
Tied to the address, the workspace, and the role. Revoked when the invite is withdrawn.
The inviter, by name
Their display name for the copy and their address for reply_to.
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.
{{INVITER_NAME}} invited you to {{TEAM_NAME}} on {{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>You have been invited</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;">Join {{TEAM_NAME}} as {{ROLE}}. The invite is open for {{EXPIRY_DAYS}} days.            </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;">{{INVITER_NAME}} invited you to {{TEAM_NAME}}</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;">{{INVITER_NAME}} ({{INVITER_EMAIL}}) has invited you to join {{TEAM_NAME}} on {{PRODUCT_NAME}} as {{ROLE}}.</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="{{ACCEPT_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;">Accept the invitation</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="{{ACCEPT_URL}}" style="color:#3f3f46;text-decoration:underline;word-break:break-all;">{{ACCEPT_URL}}</a></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%">Workspace</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;">{{TEAM_NAME}}</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%">Your role</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;">{{ROLE}}</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%">Invite expires</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;">in {{EXPIRY_DAYS}} days</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;">Not expecting this? Reply to this email and it goes straight to {{INVITER_NAME}}. If you ignore it, the invite expires on its own and you get nothing further.</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 {{INVITER_EMAIL}} invited 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.
A member invites an address
You check that they are allowed to, and that the seat exists.
You mint an invite
Bound to the address, the workspace, and the role, with an expiry.
You send this
From your domain, with reply_to set to the inviter.
They accept or it lapses
On accept you create the membership at exactly the role named here.
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',
INVITER_NAME: 'Grace Hopper',
INVITER_EMAIL: 'grace@northwind.com',
TEAM_NAME: 'Northwind Engineering',
ROLE: 'Editor',
ACCEPT_URL: 'https://acme.com/invites/9d21ff',
EXPIRY_DAYS: '7',
};
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('{{INVITER_NAME}} invited you to {{TEAM_NAME}} on {{PRODUCT_NAME}}'),
html: fill(readFileSync('email.html', 'utf8')),
text: fill(readFileSync('email.txt', 'utf8')),
replyTo: vars.INVITER_EMAIL,
tags: { type: 'team-invite' }
});
// Queued, not delivered. Ask for the id later, or take the webhook.
console.log(id);Questions
Should the From address be the inviter?
No. Sending as a person at a domain you do not control fails DMARC and lands in spam. Put their name in the subject and the first line, send from your own domain, and set reply_to to them.
What if they already have an account?
Send the same email. The accept link decides: an existing session joins the workspace directly, and anyone else signs in first and lands on the same accept step.
Why show the role?
Because accepting is a permission decision for both sides. Somebody who was told they were getting read-only access and sees Editor here will ask, which is exactly the moment you want the question raised.
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 onboarding templates