Comment notification
The comment itself, in the email, with a way to turn these off.
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.
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.
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.
{{AUTHOR_NAME}} commented on {{THREAD_TITLE}}<!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}}            </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.
Somebody comments
You resolve who is following the thread and who is muted.
You wait a moment
A short window turns a burst of five comments into one email.
You send this
With References set to the thread, so the inbox groups it.
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.
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