# Weekly digest

A weekly digest email template with a headline number, a short list, a one-click unsubscribe, and List-Unsubscribe headers. Table-based HTML, a text alternative, and code in TypeScript, Python, and curl.

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.

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.

Topics: weekly digest, summary email, unsubscribe, list-unsubscribe.

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

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

## Subject

```text
Your week on {{PRODUCT_NAME}}: {{HEADLINE_STAT}}
```

## 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>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}}.&#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;">{{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;">&nbsp;</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>
```

## Plain text body

```text
{{HEADLINE_STAT}}
{{PRODUCT_NAME}}, {{WEEK_RANGE}}

{{FIRST_NAME}}, here is what moved this week.

{{ITEM_ONE_TITLE}}
{{ITEM_ONE_DETAIL}}

{{ITEM_TWO_TITLE}}
{{ITEM_TWO_DETAIL}}

See the full week:
{{DIGEST_URL}}

You are getting this weekly digest because you turned it on in
{{PRODUCT_NAME}}. Unsubscribe, one click and no sign-in:
{{UNSUBSCRIBE_URL}}
```

## Placeholders

| Placeholder | What it is | Example |
| --- | --- | --- |
| {{PRODUCT_NAME}} | Your product, as the reader knows it. | Acme |
| {{FIRST_NAME}} | What to call them. | Ada |
| {{WEEK_RANGE}} | The week covered. | 24 to 31 July |
| {{HEADLINE_STAT}} | The one number worth the interruption. | 48 deploys, 2 rollbacks |
| {{ITEM_ONE_TITLE}} | First item. | Checkout latency down 18% |
| {{ITEM_ONE_DETAIL}} | One line about it. | The p95 moved from 840ms to 690ms after the cache change on Tuesday. |
| {{ITEM_TWO_TITLE}} | Second item. | 3 failed jobs need a look |
| {{ITEM_TWO_DETAIL}} | One line about it. | All three are the nightly export, failing on the same malformed row. |
| {{DIGEST_URL}} | The full version in the app. | https://acme.com/reports/weekly |
| {{UNSUBSCRIBE_URL}} | One click, no login. Also goes in List-Unsubscribe. | https://acme.com/u/9f21ab |

## How it works

1. **A job runs weekly** It gathers the week per recipient and drops anyone with nothing to say.
2. **You check consent** Opted in, not suppressed, not bounced. A digest to a dead address is reputation.
3. **You send in a batch** POST /v1/emails/batch, up to 100 per call, with the unsubscribe headers on each.
4. **Unsubscribes take effect** Before the next run, without a login, including the one-click header path.

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

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


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("Your week on {{PRODUCT_NAME}}: {{HEADLINE_STAT}}"),
    html=fill(html),
    text=fill(text),
    # 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": "<" + values["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.
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|{{FIRST_NAME}}|Ada|g' \
      -e 's|{{WEEK_RANGE}}|24 to 31 July|g' \
      -e 's|{{HEADLINE_STAT}}|48 deploys, 2 rollbacks|g' \
      -e 's|{{ITEM_ONE_TITLE}}|Checkout latency down 18%|g' \
      -e 's|{{ITEM_ONE_DETAIL}}|The p95 moved from 840ms to 690ms after the cache change on Tuesday.|g' \
      -e 's|{{ITEM_TWO_TITLE}}|3 failed jobs need a look|g' \
      -e 's|{{ITEM_TWO_DETAIL}}|All three are the nightly export, failing on the same malformed row.|g' \
      -e 's|{{DIGEST_URL}}|https://acme.com/reports/weekly|g' \
      -e 's|{{UNSUBSCRIBE_URL}}|https://acme.com/u/9f21ab|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 'Your week on Acme: 48 deploys, 2 rollbacks' \
      --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: {"List-Unsubscribe": "<https://acme.com/u/9f21ab>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"},
        tags: {type: "weekly-digest"}}')"
```

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

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