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>
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.comordomain.com) - Optional per-feed "sender in title" toggle — renders each entry as
[Sender] Subjectfor 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-Modifiedand answer304 Not Modifiedon 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-src→src), 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 (nativeFeedsfield), 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:
- Incoming email arrives at
apple.mountain.42@yourdomain.com(the feed's inbound address). - The Worker resolves the feed from the recipient address (via the
inbound:index) and stores the email in KV. 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./adminprovides feed management and email deletion.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 ingestionsrc/routes/inbound.ts: ForwardEmail webhook ingestionsrc/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 renderingsrc/routes/opml.ts: OPML export of all feeds (admin-protected, mounted at/admin/opml)src/routes/files.ts: attachment file serving from R2src/routes/admin.tsx: admin UI + feed CRUDsrc/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-Userheaders 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=Strictcookie. - Admin responses are
no-storeto avoid cache leakage. - Feed, entry, and attachment responses send
X-Robots-Tag: noindex, and/robots.txtdisallows/rss,/atom,/entries,/files, and/admin, so private feeds and emails are kept out of search engines. - For high-value feeds, set
Allowed sendersso 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