Comment notification

The comment itself, in the email, with a way to turn these off.

Get an API key

Notifications · 7 placeholders

The job

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.

How this one works

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.

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 somebody comments on or mentions a person who has notifications on. Batch bursts: several comments in a minute should be one email, not five.

01

An emails.sh API key

Created in the dashboard, starting with esh_. It stays on your server.

02

A sending domain

Verified with three DNS records, or send from onboarding@emails.sh until yours is ready.

03

A stable conversation id

Used to build the Message-ID and References headers so replies thread in the inbox.

04

Per-person notification settings

Checked before the send, and reachable from a link in every one of these.

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.

Subject
{{AUTHOR_NAME}} commented on {{THREAD_TITLE}}
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>

How it works

One send, start to finish. Every message is in your logs with its delivery events.

01

Somebody comments

You resolve who is following the thread and who is muted.

02

You wait a moment

A short window turns a burst of five comments into one email.

03

You send this

With References set to the thread, so the inbox groups it.

04

They reply in the app

Or by mail, if you route inbound back into the thread.

Code

The same send three ways. Each one fills the placeholders, reads the two bodies, and posts them to POST /v1/emails.

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);

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.

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 notifications templates