Julien Herr 44fcbfc4f6 fix(favicon): fall back to apex domain when subdomain hosts no icon
Senders on a subdomain that hosts no favicon (e.g. mail.example.com) left
feeds blank because both the direct /favicon.ico and the DuckDuckGo lookup
were tried only against the full subdomain. Resolution now walks up to the
apex via Domain.parents() and caches the result under the original sender
domain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:49:43 +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%