Julien Herr e258206384 fix(feed): advertise WebSub hub in RSS/Atom body
Readers like FreshRSS discover the hub from <atom:link rel="hub"> in the
feed XML, not the HTTP Link header. Without it they never subscribe and
only refresh on cache expiry (~30 min) instead of receiving an instant
push when a new email arrives.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:04:33 +02:00
2026-05-21 12:09:26 +02:00
2025-02-27 10:57:55 -08:00
2026-05-25 19:01:44 +02:00

kill-the-news

Convert email newsletters into private RSS feeds using Cloudflare Workers.

Self-hosted, uses your own domain, and keeps your data in your own Cloudflare account. Live at kill-the.news.

Why this exists

Many newsletters only support email delivery. RSS readers offer a better reading experience, but getting email-only newsletters into RSS usually means relying on shared third-party infrastructure.

kill-the-news keeps the same workflow while avoiding shared domains and shared data stores.

Features

  • One-click feed creation from an admin dashboard
  • Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow)
  • Inline double-confirm delete interactions with toast feedback in the admin dashboard
  • Resizable + sortable table columns in the admin dashboard (Table view)
  • Per-feed "Subscribe" chips in the admin dashboard — copy, open, or validate the feed in one click for each of RSS, Atom, and JSON Feed (validation via the W3C Feed Validator and validator.jsonfeed.org)
  • Unique newsletter addresses per feed (for example apple.mountain.42@yourdomain.com)
  • Separate inbound address and feed URL — the address you subscribe with (apple.mountain.42@yourdomain.com) and the public feed URL (/rss/<opaque-id>) use independent ids, so you can share a feed without leaking the address that feeds it, and an address harvested by a newsletter can't be used to read your feed (/rss/<your-address> 404s)
  • Cloudflare Email Workers ingestion (no third-party service)
  • ForwardEmail webhook ingestion with source-IP verification (optional alternative)
  • Optional per-feed sender allowlist (email@domain.com or domain.com)
  • Optional per-feed "sender in title" toggle — renders each entry as [Sender] Subject for at-a-glance scanning in your reader
  • RSS generation on demand (/rss/:feedId)
  • Atom feed at /atom/:feedId
  • JSON Feed at /json/:feedId (natively consumed by NetNewsWire, Reeder, NewsBlur, Feedly)
  • Bandwidth-friendly polling: RSS/Atom send a strong ETag + Last-Modified and answer 304 Not Modified on conditional requests
  • Duplicate-send dedup: a newsletter delivered twice (matched by Message-ID, then by a content hash) is stored once
  • OPML export of all feeds at /admin/opml (admin-protected) for one-click bulk import into any reader
  • Reader-friendly output: relative links/images absolutized to the sender's site, lazy-loaded images promoted (data-srcsrc), plain-text feed titles, and XML-illegal control characters stripped so feeds parse in strict readers
  • Per-feed favicon derived from the last sender's domain (/favicon/:feedId), cached and shown in feeds + admin
  • Automatic RFC 8058 one-click unsubscribe when a feed is deleted — stops newsletters from mailing the now-dead address
  • Subscription confirmation surfacing — at ingestion the worker detects "confirm your subscription" emails (multilingual keyword + link scoring) and surfaces them in the admin: a dedicated section with a primary "Confirm subscription" button on the email detail page, a "Confirmation" badge in the email list, a "Confirmation pending" pill on the dashboard, and a banner on the feed's emails page with a "Mark as confirmed" dismiss button; v1 surfaces the link only — no outbound request is made
  • Native feed detection — when a newsletter advertises its own RSS/Atom/JSON feed via <link rel="alternate"> in the email HTML, KTN surfaces it in the admin (a "Native feeds" chip group on the email detail page, a dashboard pill, and a dismissable banner) and on the REST API (nativeFeeds field), so you can subscribe to the source directly
  • Email attachments stored in Cloudflare R2 and exposed as RSS enclosures (optional)
  • Cloudflare KV storage for feed config + email metadata/content
  • Password-protected admin UI
  • Versioned REST API (/api/v1/*) with an OpenAPI 3.1 spec and Scalar docs for automation

Architecture

Two ingestion methods are supported — pick one or use both:

Method How it works
Cloudflare Email Workers Cloudflare Email Routing delivers the raw message directly to the Worker via the email() handler — no outbound webhook needed
ForwardEmail webhook ForwardEmail parses the message and POSTs a JSON payload to POST /api/inbound; the Worker verifies the source IP before processing

Common path:

  1. Incoming email arrives at apple.mountain.42@yourdomain.com (the feed's inbound address).
  2. The Worker resolves the feed from the recipient address (via the inbound: index) and stores the email in KV.
  3. https://yourdomain.com/rss/<opaque-feed-id> renders RSS from stored items — note the feed id is a separate opaque token, not the inbound address.
  4. /admin provides feed management and email deletion.
  5. https://yourdomain.com/ shows a public status page with monitoring counters and a link to the admin.

Main routes:

  • src/lib/cloudflare-email.ts: Cloudflare Email Workers ingestion
  • src/routes/inbound.ts: ForwardEmail webhook ingestion
  • src/routes/rss.ts: RSS rendering (with conditional-GET / ETag support)
  • src/routes/atom.ts: Atom feed rendering (with conditional-GET / ETag support)
  • src/routes/json.ts: JSON Feed rendering
  • src/routes/opml.ts: OPML export of all feeds (admin-protected, mounted at /admin/opml)
  • src/routes/files.ts: attachment file serving from R2
  • src/routes/admin.tsx: admin UI + feed CRUD
  • src/routes/api/: versioned REST API + OpenAPI spec/docs (/api/v1/*, /api/openapi.json, /api/docs)
  • src/lib/feed-service.ts: shared feed create/update/delete (used by the admin UI and the REST API)
  • src/routes/home.tsx: public status page (GET /)

Monitoring

GET /api/v1/stats returns JSON counters (public, no auth, CORS-enabled) for uptime/monitoring tools and the landing page:

Field Meaning
active_feeds Feeds currently configured (live)
feeds_created Total feeds ever created (cumulative)
feeds_deleted Total feeds ever deleted (cumulative)
emails_received Total emails ingested successfully (cumulative)
emails_rejected Total emails rejected during validation (cumulative)
websub_subscriptions_active Active WebSub subscriptions (live)
last_email_at ISO 8601 date-time of the last ingested email
last_feed_created_at ISO 8601 date-time of the last feed creation
first_seen ISO 8601 date-time the instance first recorded a counter

The same figures are rendered on the public status page at GET /. Cumulative counters are persisted in the EMAIL_STORAGE KV under the stats:counters key.

REST API

A versioned REST API lets you automate feed and email management without scraping the admin UI. The OpenAPI 3.1 spec is served at GET /api/openapi.json and a rendered reference (Scalar) at GET /api/docs — both public.

The feed and email endpoints require authentication, using either:

  • Bearer token: Authorization: Bearer <ADMIN_PASSWORD>, or
  • Reverse-proxy auth: the same trusted-IP + X-Auth-Proxy-Secret + Remote-User headers as the admin UI (see INSTALL.md).

GET /api/v1/stats, the OpenAPI spec, and the docs page are public.

Method Path Auth Purpose
GET /api/v1/feeds yes List feeds
POST /api/v1/feeds yes Create a feed
GET /api/v1/feeds/{feedId} yes Get a feed
PATCH /api/v1/feeds/{feedId} yes Update a feed
DELETE /api/v1/feeds/{feedId} yes Delete a feed
GET /api/v1/feeds/{feedId}/emails yes List a feed's emails
GET /api/v1/feeds/{feedId}/emails/{id} yes Get a single email
DELETE /api/v1/feeds/{feedId}/emails/{id} yes Delete a single email
GET /api/v1/stats public Read monitoring counters

The email {id} is the email's receivedAt timestamp (as returned by the list endpoint).

# Create a feed
curl -X POST https://yourdomain.com/api/v1/feeds \
  -H "Authorization: Bearer $ADMIN_PASSWORD" \
  -H 'Content-Type: application/json' \
  -d '{"title":"Daily Digest","allowedSenders":["news@example.com"]}'

Installation

See INSTALL.md for the full setup, deployment, and configuration guide. Quick start:

npx wrangler login
bash setup.sh        # prompts for admin password + domain, provisions KV, generates wrangler.toml
npm run deploy       # deploys the Worker and registers your custom domain

Then enable email ingestion (Cloudflare Email Workers or ForwardEmail) and open https://yourdomain.com/admin. Details, options, and configuration knobs (feed size limit, R2 attachments, reverse-proxy auth, CI deploys) are all in INSTALL.md.

Security notes

  • When using Option B (ForwardEmail), inbound webhook access is IP-restricted to ForwardEmail MX sources.
  • Admin auth uses a signed, HttpOnly, Secure, SameSite=Strict cookie.
  • Admin responses are no-store to avoid cache leakage.
  • Feed, entry, and attachment responses send X-Robots-Tag: noindex, and /robots.txt disallows /rss, /atom, /entries, /files, and /admin, so private feeds and emails are kept out of search engines.
  • For high-value feeds, set Allowed senders so only known sender addresses/domains are accepted.
  • You should use a strong admin password and rotate periodically.
  • All secret comparisons (admin password, proxy secret) use constant-time comparison to prevent timing attacks.

Acknowledgements

  • kill-the-newsletter by Leandro Facchinetti — the inspiration for this project and the reference implementation for feature ideas (Atom feeds, attachment enclosures, entry HTML views, and more).
  • Email-to-RSS by yl8976 — the initial codebase this project is based on.

License

MIT

S
Description
No description provided
Readme MIT 1.3 MiB
Languages
TypeScript 91.8%
CSS 6.1%
Shell 1.8%
JavaScript 0.3%