# 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](https://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/`) 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/` 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-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 `` 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/` 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 `, or - **Reverse-proxy auth**: the same trusted-IP + `X-Auth-Proxy-Secret` + `Remote-User` headers as the admin UI (see [INSTALL.md](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). ```bash # 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](INSTALL.md)** for the full setup, deployment, and configuration guide. Quick start: ```bash 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](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](https://github.com/leafac/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](https://github.com/yl8976/Email-to-RSS) by yl8976 — the initial codebase this project is based on. ## License MIT