email-processor parsed input.from twice — once via EmailAddress for the native-feed base, once via the favicon infra helper extractEmailDomain just to get the domain. CLAUDE.md forbids reaching across a layer to parse a domain: parse once and derive both siteBaseUrl() and domain.value from the EmailAddress VO, removing the infrastructure/favicon-fetcher import. 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