This site is not affiliated with or endorsed by Cloudflare, Inc. It showcases deployable experiments across most Cloudflare products.
AI & Machine Learning

Newsletter Digest

Forward newsletters to Email Routing and get one Workers AI-summarized daily digest - email + AI + cron pipeline

Forward newsletters to an Email Routing address and get one digest email per day instead. An email() handler parses each message with postal-mime, summarizes it with Workers AI, and stores it in D1; a daily Cron Trigger sends one digest grouped by sender via the send_email binding.

Features

  • Inbound email() handler with postal-mime parsing; messages over 1 MB are rejected
  • Optional ALLOWED_SENDERS allowlist (addresses or domains), checked against both envelope sender and From header
  • HTML-only newsletters are stripped to plain text before summarizing
  • 2–3 bullet summaries from @cf/meta/llama-3.1-8b-instruct-fast, falling back to the first 300 characters if AI fails
  • First non-unsubscribe link kept as a "Read more" link
  • Daily digest (text + HTML) grouped by sender; items are marked digested only after the send succeeds

API Reference

GET /items

List the latest 100 items, newest first.

pending "true" | "false" (optional)

true returns only items not yet included in a digest.

curl "https://your-worker.workers.dev/items?pending=true"
{
  "count": 1,
  "items": [
    {
      "id": 7,
      "from": "news@example.com",
      "fromName": "Example News",
      "subject": "Issue #42",
      "summary": "- Big launch this week\n- Pricing changes in November",
      "link": "https://example.com/42",
      "receivedAt": "2026-10-01T05:12:00.000Z",
      "digestedAt": null
    }
  ]
}

POST /digest/preview

Build the digest from pending items (up to 100) without sending or marking anything.

curl -X POST "https://your-worker.workers.dev/digest/preview"
{
  "subject": "Newsletter digest 2026-10-01: 1 item",
  "text": "Newsletter digest 2026-10-01: 1 item\n\n== Example News <news@example.com> ==\n\nIssue #42\n  - Big launch this week\n  - Pricing changes in November\n  https://example.com/42",
  "html": "<!doctype html><html>...</html>",
  "itemCount": 1,
  "senderCount": 1
}

POST /digest/send

Send the digest now to DIGEST_TO and mark the included items digested. Requires Authorization: Bearer <ADMIN_TOKEN>.

curl -X POST "https://your-worker.workers.dev/digest/send" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
{ "sent": true, "itemCount": 1, "messageId": "..." }

When nothing is pending, the response is { "sent": false, "itemCount": 0 } and no email is sent.

Error Codes

  • 400 - INVALID_QUERY (bad pending value), MISSING_CONFIG (DIGEST_TO not set)
  • 401 - UNAUTHORIZED
  • 502 - SEND_ERROR (Email Sending rejected the digest; items stay pending)
  • 500 - INTERNAL_ERROR

Architecture

newsletter → Email Routing (digest@yourdomain.com) → email() handler
  ├─ > 1 MB → setReject
  ├─ sender not allowed → ignored
  └─ postal-mime → text / html→text → Workers AI summary → D1 items (digested_at NULL)

cron 0 7 * * * → pending items → group by sender → send_email → digested_at = now

Use Cases

  • Cut a dozen daily newsletters down to one email
  • Learn the inbound email() handler, Workers AI, D1, Cron, and Email Sending together
  • Starting point for any "collect mail, process later" pipeline (receipts, alerts, reports)

Limitations

  • GET /items and POST /digest/preview are public; put them behind Cloudflare Access or add the admin check if summaries are sensitive
  • HTML-to-text is regex-based and rough on complex layouts; only the first 6000 characters are sent to the model
  • Link extraction picks the first non-unsubscribe link, which may be a header or sponsor link
  • One digest holds at most 100 items; the rest go in the next run
  • Messages are processed one at a time in the email() handler, so the AI call adds latency to delivery

Use in your project

Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/newsletter-digest.

Dependenciespostal-mimeBindingsAIPlatformEmail Workers (`email()` handler), Workers AI
Install
npm install postal-mime
const NAMED_ENTITIES: Record<string, string> = {  amp: "&",  lt: "<",  gt: ">",  quot: '"',  apos: "'",  nbsp: " ",  mdash: "—",  ndash: "–",  hellip: "…",  rsquo: "’",  lsquo: "‘",  rdquo: "”",  ldquo: "“",};const SKIP_LINK = /unsubscribe|preferences|manage[-_ ]?subscription|view[-_ ]?in[-_ ]?browser/i;function decodeEntities(text: string): string {  return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {    if (entity[0] === "#") {      const code =        entity[1] === "x" || entity[1] === "X"          ? Number.parseInt(entity.slice(2), 16)          : Number.parseInt(entity.slice(1), 10);      return Number.isFinite(code) && code > 0 && code <= 0x10ffff        ? String.fromCodePoint(code)        : "";    }    return NAMED_ENTITIES[entity.toLowerCase()] ?? match;  });}/** Deliberately simple: good enough to feed an LLM, not a faithful renderer. */export function htmlToText(html: string): string {  const text = html    .replace(/<!--[\s\S]*?-->/g, "")    .replace(/<(head|style|script|noscript|title)\b[^>]*>[\s\S]*?<\/\1>/gi, "")    .replace(/<br\s*\/?>/gi, "\n")    .replace(/<li\b[^>]*>/gi, "\n- ")    .replace(/<\/(p|div|h[1-6]|ul|ol|tr|table|blockquote|section|article)>/gi, "\n")    .replace(/<[^>]+>/g, "");  return decodeEntities(text)    .replace(/[\u200b-\u200d\ufeff\u034f]/g, "")    .replace(/[ \t\f\v\u00a0]+/g, " ")    .replace(/ *\n */g, "\n")    .replace(/\n{3,}/g, "\n\n")    .trim();}export function extractFirstLink(html: string | undefined, text: string): string | null {  const candidates: string[] = [];  if (html) {    for (const match of html.matchAll(/<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi)) {      candidates.push(decodeEntities(match[1]));    }  }  for (const match of text.matchAll(/https?:\/\/[^\s<>()"']+/g)) {    candidates.push(match[0]);  }  return candidates.find((url) => /^https?:\/\//i.test(url) && !SKIP_LINK.test(url)) ?? null;}

Deployment

Click the deploy button

Deploy to Cloudflare Workers

Apply migrations and set the admin token

npx wrangler d1 migrations apply newsletter-digest-db --remote
npx wrangler secret put ADMIN_TOKEN

Route inbound mail to the Worker

In the dashboard, enable Email Routing on your zone and create a custom address (e.g. digest@yourdomain.com) with the action Send to a Worker → newsletter-digest. Subscribe or forward newsletters to that address.

Configure the digest sender

npx wrangler email sending enable yourdomain.com

Set DIGEST_FROM (on the onboarded domain), DIGEST_TO, and optionally ALLOWED_SENDERS in wrangler.json, then redeploy.

Test your deployment

curl "https://your-worker.workers.dev/items?pending=true"
curl -X POST "https://your-worker.workers.dev/digest/preview"

Local Development

cd apps/experiments/newsletter-digest
npm install
npx wrangler d1 migrations apply newsletter-digest-db --local
npx wrangler dev --test-scheduled

Simulate an inbound email, then preview and trigger the cron:

curl -X POST "http://localhost:8787/cdn-cgi/handler/email?from=news@example.com&to=digest@example.com" \
  --data-binary $'From: Example News <news@example.com>\r\nTo: digest@example.com\r\nSubject: Issue 1\r\nContent-Type: text/plain\r\n\r\nBig news this week. https://example.com/1'

curl -X POST "http://localhost:8787/digest/preview"
curl "http://localhost:8787/__scheduled?cron=0+7+*+*+*"

Configuration

wrangler.json declares:

  • Workers AI AI
  • D1 DB (migrations/0000_init.sql creates items)
  • send_email binding EMAIL
  • Cron 0 7 * * *
  • Vars DIGEST_FROM, DIGEST_TO, ALLOWED_SENDERS (comma-separated addresses or domains; empty accepts all)

Secret: ADMIN_TOKEN. Dependency: postal-mime.

Cloudflare Features Used

On this page