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 withpostal-mimeparsing; messages over 1 MB are rejected - Optional
ALLOWED_SENDERSallowlist (addresses or domains), checked against both envelope sender andFromheader - 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(badpendingvalue),MISSING_CONFIG(DIGEST_TOnot set)401-UNAUTHORIZED502-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 = nowUse 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 /itemsandPOST /digest/previeware 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.
npm install postal-mimeconst 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
Apply migrations and set the admin token
npx wrangler d1 migrations apply newsletter-digest-db --remote
npx wrangler secret put ADMIN_TOKENRoute 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.comSet 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-scheduledSimulate 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.sqlcreatesitems) send_emailbindingEMAIL- 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
- Email Routing / Email Workers - Inbound
email()handler - Workers AI - Newsletter summaries
- D1 - Item storage and digest state
- Cron Triggers - Daily digest
- Email Sending - Digest delivery via
send_email
Natural Language Calendar
Turn plain English into a calendar invite with Workers AI JSON mode - parse events and return .ics-ready structured data
Clef Decision (Jev-compatible)New
Jev / System One API-compatible decision model on Workers AI - Clef & Clef-Flash return probabilities for noul/choice questions without free-form text