# Comment notification

A comment or mention notification template that quotes the comment, threads by conversation, and links notification settings. Table-based HTML, a text alternative, and code in TypeScript, Python, and curl.

A notification that says "you have a new comment" and nothing else forces a round trip to the app for information that fits in the email. Quote the comment. Most of the time reading it is the whole of what the person needed to do.

The two things that keep this email from being resented are threading and a settings link. References and In-Reply-To keep a busy conversation in one place in the inbox, and the settings link makes turning it off a choice inside your product rather than a spam complaint.

Topics: comment notification, mention, activity email, threading.

## When it sends

When somebody comments on or mentions a person who has notifications on. Batch bursts: several comments in a minute should be one email, not five.

## 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 stable conversation id**: Used to build the Message-ID and References headers so replies thread in the inbox.
- **Per-person notification settings**: Checked before the send, and reachable from a link in every one of these.

## Subject

```text
{{AUTHOR_NAME}} commented on {{THREAD_TITLE}}
```

## 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>New comment</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;">{{COMMENT_BODY}}&#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;">{{AUTHOR_NAME}} commented</h1>
<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;">On {{THREAD_TITLE}}, {{COMMENT_TIME}}</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td style="padding:16px;background-color:#f4f4f5;border-left:3px solid #18181b;font-family:Helvetica,Arial,sans-serif;font-size:15px;line-height:24px;color:#3f3f46;">{{COMMENT_BODY}}</td></tr></table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td bgcolor="#18181b" style="background-color:#18181b;"><a href="{{REPLY_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;">Reply in {{PRODUCT_NAME}}</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="{{REPLY_URL}}" style="color:#3f3f46;text-decoration:underline;word-break:break-all;">{{REPLY_URL}}</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;">You are getting this because you follow {{THREAD_TITLE}}.</p>
<p style="margin:0 0 8px;font-family:Helvetica,Arial,sans-serif;font-size:12px;line-height:18px;color:#71717a;">Turn these off: <a href="{{SETTINGS_URL}}" style="color:#18181b;text-decoration:underline;">{{SETTINGS_URL}}</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
```

## Plain text body

```text
{{AUTHOR_NAME}} commented on {{THREAD_TITLE}}
{{COMMENT_TIME}}

  {{COMMENT_BODY}}

Reply:
{{REPLY_URL}}

You are getting this because you follow {{THREAD_TITLE}}.
Turn these off: {{SETTINGS_URL}}
```

## Placeholders

| Placeholder | What it is | Example |
| --- | --- | --- |
| {{PRODUCT_NAME}} | Your product, as the reader knows it. | Acme |
| {{AUTHOR_NAME}} | Who commented. | Grace Hopper |
| {{THREAD_TITLE}} | What they commented on. | Q3 launch checklist |
| {{COMMENT_BODY}} | The comment, escaped for HTML before it goes in. | Shipping this on Friday needs the migration merged first. |
| {{COMMENT_TIME}} | When it was posted. | 31 July 2026 at 09:12 UTC |
| {{REPLY_URL}} | The comment, deep linked. | https://acme.com/threads/812#c-4471 |
| {{SETTINGS_URL}} | Where these notifications are turned off. | https://acme.com/settings/notifications |

## How it works

1. **Somebody comments** You resolve who is following the thread and who is muted.
2. **You wait a moment** A short window turns a burst of five comments into one email.
3. **You send this** With References set to the thread, so the inbox groups it.
4. **They reply in the app** Or by mail, if you route inbound back into the thread.

## 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',
  AUTHOR_NAME: 'Grace Hopper',
  THREAD_TITLE: 'Q3 launch checklist',
  COMMENT_BODY: 'Shipping this on Friday needs the migration merged first.',
  COMMENT_TIME: '31 July 2026 at 09:12 UTC',
  REPLY_URL: 'https://acme.com/threads/812#c-4471',
  SETTINGS_URL: 'https://acme.com/settings/notifications',
};

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('{{AUTHOR_NAME}} commented on {{THREAD_TITLE}}'),
  html: fill(readFileSync('email.html', 'utf8')),
  text: fill(readFileSync('email.txt', 'utf8')),
  // Threading, so a busy conversation is one item in the inbox.
  headers: {
    'References': '<thread-812@acme.com>',
    'In-Reply-To': '<thread-812@acme.com>'
  },
  tags: { type: 'comment' }
});

// 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",
    "AUTHOR_NAME": "Grace Hopper",
    "THREAD_TITLE": "Q3 launch checklist",
    "COMMENT_BODY": "Shipping this on Friday needs the migration merged first.",
    "COMMENT_TIME": "31 July 2026 at 09:12 UTC",
    "REPLY_URL": "https://acme.com/threads/812#c-4471",
    "SETTINGS_URL": "https://acme.com/settings/notifications",
}


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("{{AUTHOR_NAME}} commented on {{THREAD_TITLE}}"),
    html=fill(html),
    text=fill(text),
    # Threading, so a busy conversation is one item in the inbox.
    headers={
        "References": "<thread-812@acme.com>",
        "In-Reply-To": "<thread-812@acme.com>",
    },
    tags={"type": "comment"},
)

# 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|{{AUTHOR_NAME}}|Grace Hopper|g' \
      -e 's|{{THREAD_TITLE}}|Q3 launch checklist|g' \
      -e 's|{{COMMENT_BODY}}|Shipping this on Friday needs the migration merged first.|g' \
      -e 's|{{COMMENT_TIME}}|31 July 2026 at 09:12 UTC|g' \
      -e 's|{{REPLY_URL}}|https://acme.com/threads/812#c-4471|g' \
      -e 's|{{SETTINGS_URL}}|https://acme.com/settings/notifications|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 commented on Q3 launch checklist' \
      --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,
        headers: {"References": "<thread-812@acme.com>", "In-Reply-To": "<thread-812@acme.com>"},
        tags: {type: "comment"}}')"
```

## Questions

### How do I get these to thread in Gmail?

Send a stable Message-ID per thread and set References and In-Reply-To to it on every notification for that conversation. Gmail also groups on a matching subject, so keep the subject stable rather than numbering it.

### Should the comment body really be in the email?

Yes for most products, because reading it is usually the entire task. If the content is sensitive, send the title and the author and stop there, and say in the settings page why.

### What about escaping?

Escape the comment before it goes into the HTML body. A comment containing a tag is a stored injection into an email you are about to send to somebody else, and the mail client will render it.

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