Weekly digest
One email a week, one number at the top, and a real unsubscribe.
Notifications · 10 placeholders
The job
A digest is the one email in this set that the reader did not ask for individually, so it carries the obligations that come with that: a visible unsubscribe, the List-Unsubscribe headers, and a headline worth the interruption.
How this one works
The structure that survives is a number at the top and three items under it. A digest that lists everything gets skimmed to nothing, and a digest with no number at the top gets opened for the first month and then filtered.
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
On a schedule, to people who are opted in and have something to show. An empty week is a week you skip, not a week you send an empty digest.
A sending domain
Verified with three DNS records, or send from onboarding@emails.sh until yours is ready.
An opt-in you can prove
When and how each recipient agreed. This is the email somebody will complain about.
A working unsubscribe
One click, no login, effective on the next run. Both in the body and in the headers.
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.
Your week on {{PRODUCT_NAME}}: {{HEADLINE_STAT}}<!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>Your weekly digest</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;">{{WEEK_RANGE}}. {{HEADLINE_STAT}}.            </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;">{{HEADLINE_STAT}}</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;">{{PRODUCT_NAME}}, {{WEEK_RANGE}}</p>
<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;">{{FIRST_NAME}}, here is what moved this week.</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 20px;"><tr><td height="1" style="height:1px;background-color:#e4e4e7;font-size:0;line-height:0;"> </td></tr></table>
<h2 style="margin:0 0 6px;font-family:Helvetica,Arial,sans-serif;font-size:16px;line-height:22px;font-weight:600;color:#18181b;">{{ITEM_ONE_TITLE}}</h2>
<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;">{{ITEM_ONE_DETAIL}}</p>
<h2 style="margin:0 0 6px;font-family:Helvetica,Arial,sans-serif;font-size:16px;line-height:22px;font-weight:600;color:#18181b;">{{ITEM_TWO_TITLE}}</h2>
<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;">{{ITEM_TWO_DETAIL}}</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="{{DIGEST_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;">See the full week</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="{{DIGEST_URL}}" style="color:#3f3f46;text-decoration:underline;word-break:break-all;">{{DIGEST_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 weekly digest because you turned it on in {{PRODUCT_NAME}}.</p>
<p style="margin:0 0 8px;font-family:Helvetica,Arial,sans-serif;font-size:12px;line-height:18px;color:#71717a;"><a href="{{UNSUBSCRIBE_URL}}" style="color:#18181b;text-decoration:underline;">Unsubscribe from the digest</a>. One click, no sign-in.</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 job runs weekly
It gathers the week per recipient and drops anyone with nothing to say.
You check consent
Opted in, not suppressed, not bounced. A digest to a dead address is reputation.
You send in a batch
POST /v1/emails/batch, up to 100 per call, with the unsubscribe headers on each.
Unsubscribes take effect
Before the next run, without a login, including the one-click header path.
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',
FIRST_NAME: 'Ada',
WEEK_RANGE: '24 to 31 July',
HEADLINE_STAT: '48 deploys, 2 rollbacks',
ITEM_ONE_TITLE: 'Checkout latency down 18%',
ITEM_ONE_DETAIL: 'The p95 moved from 840ms to 690ms after the cache change on Tuesday.',
ITEM_TWO_TITLE: '3 failed jobs need a look',
ITEM_TWO_DETAIL: 'All three are the nightly export, failing on the same malformed row.',
DIGEST_URL: 'https://acme.com/reports/weekly',
UNSUBSCRIBE_URL: 'https://acme.com/u/9f21ab',
};
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('Your week on {{PRODUCT_NAME}}: {{HEADLINE_STAT}}'),
html: fill(readFileSync('email.html', 'utf8')),
text: fill(readFileSync('email.txt', 'utf8')),
// Both headers, so Gmail and Outlook show their own unsubscribe
// control and a tired reader never has to reach for the spam button.
headers: {
'List-Unsubscribe': '<' + vars.UNSUBSCRIBE_URL + '>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
},
tags: { type: 'weekly-digest' }
});
// Queued, not delivered. Ask for the id later, or take the webhook.
console.log(id);Questions
Why do the List-Unsubscribe headers matter?
They put an unsubscribe control in the client interface, next to the sender. Gmail and Outlook both require them for bulk senders now, and the reader who would otherwise press the spam button uses them instead, which protects the domain the rest of your mail goes out on.
Should the digest go from a different subdomain?
Yes, once you have any volume. Keep bulk mail on its own subdomain so a bad digest week cannot damage delivery of password resets sent from the main one.
What if there is nothing to report?
Skip the send. A digest with nothing in it teaches people that the email is not worth opening, and that lesson holds for the weeks when it is.
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