# Team invite

A team invitation email template naming the inviter, the workspace, and the role, with an expiring accept link. Table-based HTML, a text alternative, and code in TypeScript, Python, and curl.

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.

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.

Topics: team invite, workspace invitation, collaboration, onboarding.

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

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

## Subject

```text
{{INVITER_NAME}} invited you to {{TEAM_NAME}} on {{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>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.&#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;">{{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>
```

## Plain text body

```text
{{INVITER_NAME}} invited you to {{TEAM_NAME}}

{{INVITER_NAME}} ({{INVITER_EMAIL}}) has invited you to join {{TEAM_NAME}}
on {{PRODUCT_NAME}} as {{ROLE}}.

Accept the invitation:
{{ACCEPT_URL}}

Workspace:       {{TEAM_NAME}}
Your role:       {{ROLE}}
Invite expires:  in {{EXPIRY_DAYS}} days

Not expecting this? Reply to this email and it goes straight to
{{INVITER_NAME}}. If you ignore it, the invite expires on its own.
```

## Placeholders

| Placeholder | What it is | Example |
| --- | --- | --- |
| {{PRODUCT_NAME}} | Your product, as the reader knows it. | Acme |
| {{INVITER_NAME}} | Who is inviting. Goes in the subject. | Grace Hopper |
| {{INVITER_EMAIL}} | Their address, used as reply_to. | grace@northwind.com |
| {{TEAM_NAME}} | The workspace being joined. | Northwind Engineering |
| {{ROLE}} | What they will be able to do. | Editor |
| {{ACCEPT_URL}} | The accept link, carrying the invite token. | https://acme.com/invites/9d21ff |
| {{EXPIRY_DAYS}} | How long the invite stays open. | 7 |

## How it works

1. **A member invites an address** You check that they are allowed to, and that the seat exists.
2. **You mint an invite** Bound to the address, the workspace, and the role, with an expiry.
3. **You send this** From your domain, with reply_to set to the inviter.
4. **They accept or it lapses** On accept you create the membership at exactly the role named here.

## 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',
  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);
```

### 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",
    "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",
}


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("{{INVITER_NAME}} invited you to {{TEAM_NAME}} on {{PRODUCT_NAME}}"),
    html=fill(html),
    text=fill(text),
    reply_to=values["INVITER_EMAIL"],
    tags={"type": "team-invite"},
)

# 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|{{INVITER_NAME}}|Grace Hopper|g' \
      -e 's|{{INVITER_EMAIL}}|grace@northwind.com|g' \
      -e 's|{{TEAM_NAME}}|Northwind Engineering|g' \
      -e 's|{{ROLE}}|Editor|g' \
      -e 's|{{ACCEPT_URL}}|https://acme.com/invites/9d21ff|g' \
      -e 's|{{EXPIRY_DAYS}}|7|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 'Grace Hopper invited you to Northwind Engineering on 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,
        reply_to: "grace@northwind.com",
        tags: {type: "team-invite"}}')"
```

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

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