{"id":50382633,"url":"https://github.com/juherr/kill-the-news","last_synced_at":"2026-05-30T13:01:29.459Z","repository":{"id":359218783,"uuid":"1244937543","full_name":"juherr/kill-the-news","owner":"juherr","description":"Self-hosted Cloudflare Worker that turns email newsletters into private RSS feeds — your data, your domain, your reader.","archived":false,"fork":false,"pushed_at":"2026-05-21T21:46:53.000Z","size":367,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-22T03:46:20.936Z","etag":null,"topics":["atom","kill-the-news","mail","newsletter","rss"],"latest_commit_sha":null,"homepage":"https://kill-the.news","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":"yl8976/Email-to-RSS","license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/juherr.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":"AGENTS.md","dco":null,"cla":null},"funding":{"github":["juherr"]}},"created_at":"2026-05-20T18:44:39.000Z","updated_at":"2026-05-21T21:46:58.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/juherr/kill-the-news","commit_stats":null,"previous_names":["juherr/email-to-rss","juherr/kill-the-news"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/juherr/kill-the-news","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/juherr%2Fkill-the-news","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/juherr%2Fkill-the-news/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/juherr%2Fkill-the-news/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/juherr%2Fkill-the-news/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/juherr","download_url":"https://codeload.github.com/juherr/kill-the-news/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/juherr%2Fkill-the-news/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33692997,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-30T02:00:06.278Z","response_time":92,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["atom","kill-the-news","mail","newsletter","rss"],"created_at":"2026-05-30T13:01:28.727Z","updated_at":"2026-05-30T13:01:29.453Z","avatar_url":"https://github.com/juherr.png","language":"TypeScript","funding_links":["https://github.com/sponsors/juherr"],"categories":[],"sub_categories":[],"readme":"# kill-the-news\n\nConvert email newsletters into private RSS feeds using Cloudflare Workers.\n\nSelf-hosted, uses your own domain, and keeps your data in your own Cloudflare account. Live at [kill-the.news](https://kill-the.news).\n\n## Why this exists\n\nMany 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.\n\nkill-the-news keeps the same workflow while avoiding shared domains and shared data stores.\n\n## Features\n\n- One-click feed creation from an admin dashboard\n- Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow)\n- Inline double-confirm delete interactions with toast feedback in the admin dashboard\n- Resizable + sortable table columns in the admin dashboard (Table view)\n- 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)\n- Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`)\n- **Separate inbound address and feed URL** — the address you subscribe with (`apple.mountain.42@yourdomain.com`) and the public feed URL (`/rss/\u003copaque-id\u003e`) 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/\u003cyour-address\u003e` 404s)\n- Cloudflare Email Workers ingestion (no third-party service)\n- ForwardEmail webhook ingestion with source-IP verification (optional alternative)\n- Optional per-feed sender allowlist (`email@domain.com` or `domain.com`)\n- Optional per-feed \"sender in title\" toggle — renders each entry as `[Sender] Subject` for at-a-glance scanning in your reader\n- RSS generation on demand (`/rss/:feedId`)\n- Atom feed at `/atom/:feedId`\n- JSON Feed at `/json/:feedId` (natively consumed by NetNewsWire, Reeder, NewsBlur, Feedly)\n- Bandwidth-friendly polling: RSS/Atom send a strong `ETag` + `Last-Modified` and answer `304 Not Modified` on conditional requests\n- Duplicate-send dedup: a newsletter delivered twice (matched by `Message-ID`, then by a content hash) is stored once\n- OPML export of all feeds at `/admin/opml` (admin-protected) for one-click bulk import into any reader\n- 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\n- Per-feed favicon derived from the last sender's domain (`/favicon/:feedId`), cached and shown in feeds + admin\n- Automatic RFC 8058 one-click unsubscribe when a feed is deleted — stops newsletters from mailing the now-dead address\n- **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\n- **Native feed detection** — when a newsletter advertises its own RSS/Atom/JSON feed via `\u003clink rel=\"alternate\"\u003e` 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\n- Email attachments stored in Cloudflare R2 and exposed as RSS enclosures (optional)\n- Cloudflare KV storage for feed config + email metadata/content\n- Password-protected admin UI\n- Versioned REST API (`/api/v1/*`) with an OpenAPI 3.1 spec and Scalar docs for automation\n\n## Architecture\n\nTwo ingestion methods are supported — pick one or use both:\n\n| Method                       | How it works                                                                                                                         |\n| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |\n| **Cloudflare Email Workers** | Cloudflare Email Routing delivers the raw message directly to the Worker via the `email()` handler — no outbound webhook needed      |\n| **ForwardEmail webhook**     | ForwardEmail parses the message and POSTs a JSON payload to `POST /api/inbound`; the Worker verifies the source IP before processing |\n\nCommon path:\n\n1. Incoming email arrives at `apple.mountain.42@yourdomain.com` (the feed's inbound address).\n2. The Worker resolves the feed from the recipient address (via the `inbound:` index) and stores the email in KV.\n3. `https://yourdomain.com/rss/\u003copaque-feed-id\u003e` renders RSS from stored items — note the feed id is a separate opaque token, not the inbound address.\n4. `/admin` provides feed management and email deletion.\n5. `https://yourdomain.com/` shows a public status page with monitoring counters and a link to the admin.\n\nMain routes:\n\n- `src/lib/cloudflare-email.ts`: Cloudflare Email Workers ingestion\n- `src/routes/inbound.ts`: ForwardEmail webhook ingestion\n- `src/routes/rss.ts`: RSS rendering (with conditional-GET / ETag support)\n- `src/routes/atom.ts`: Atom feed rendering (with conditional-GET / ETag support)\n- `src/routes/json.ts`: JSON Feed rendering\n- `src/routes/opml.ts`: OPML export of all feeds (admin-protected, mounted at `/admin/opml`)\n- `src/routes/files.ts`: attachment file serving from R2\n- `src/routes/admin.tsx`: admin UI + feed CRUD\n- `src/routes/api/`: versioned REST API + OpenAPI spec/docs (`/api/v1/*`, `/api/openapi.json`, `/api/docs`)\n- `src/lib/feed-service.ts`: shared feed create/update/delete (used by the admin UI and the REST API)\n- `src/routes/home.tsx`: public status page (`GET /`)\n\n### Monitoring\n\n`GET /api/v1/stats` returns JSON counters (public, no auth, CORS-enabled) for\nuptime/monitoring tools and the landing page:\n\n| Field                         | Meaning                                                  |\n| ----------------------------- | -------------------------------------------------------- |\n| `active_feeds`                | Feeds currently configured (live)                        |\n| `feeds_created`               | Total feeds ever created (cumulative)                    |\n| `feeds_deleted`               | Total feeds ever deleted (cumulative)                    |\n| `emails_received`             | Total emails ingested successfully (cumulative)          |\n| `emails_rejected`             | Total emails rejected during validation (cumulative)     |\n| `websub_subscriptions_active` | Active WebSub subscriptions (live)                       |\n| `last_email_at`               | ISO 8601 date-time of the last ingested email            |\n| `last_feed_created_at`        | ISO 8601 date-time of the last feed creation             |\n| `first_seen`                  | ISO 8601 date-time the instance first recorded a counter |\n\nThe same figures are rendered on the public status page at `GET /`. Cumulative counters\nare persisted in the `EMAIL_STORAGE` KV under the `stats:counters` key.\n\n### REST API\n\nA versioned REST API lets you automate feed and email management without scraping the\nadmin UI. The OpenAPI 3.1 spec is served at `GET /api/openapi.json` and a rendered\nreference (Scalar) at `GET /api/docs` — both public.\n\nThe feed and email endpoints require authentication, using either:\n\n- **Bearer token**: `Authorization: Bearer \u003cADMIN_PASSWORD\u003e`, or\n- **Reverse-proxy auth**: the same trusted-IP + `X-Auth-Proxy-Secret` + `Remote-User`\n  headers as the admin UI (see [INSTALL.md](INSTALL.md)).\n\n`GET /api/v1/stats`, the OpenAPI spec, and the docs page are public.\n\n| Method   | Path                                 | Auth   | Purpose                  |\n| -------- | ------------------------------------ | ------ | ------------------------ |\n| `GET`    | `/api/v1/feeds`                      | yes    | List feeds               |\n| `POST`   | `/api/v1/feeds`                      | yes    | Create a feed            |\n| `GET`    | `/api/v1/feeds/{feedId}`             | yes    | Get a feed               |\n| `PATCH`  | `/api/v1/feeds/{feedId}`             | yes    | Update a feed            |\n| `DELETE` | `/api/v1/feeds/{feedId}`             | yes    | Delete a feed            |\n| `GET`    | `/api/v1/feeds/{feedId}/emails`      | yes    | List a feed's emails     |\n| `GET`    | `/api/v1/feeds/{feedId}/emails/{id}` | yes    | Get a single email       |\n| `DELETE` | `/api/v1/feeds/{feedId}/emails/{id}` | yes    | Delete a single email    |\n| `GET`    | `/api/v1/stats`                      | public | Read monitoring counters |\n\nThe email `{id}` is the email's `receivedAt` timestamp (as returned by the list endpoint).\n\n```bash\n# Create a feed\ncurl -X POST https://yourdomain.com/api/v1/feeds \\\n  -H \"Authorization: Bearer $ADMIN_PASSWORD\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"title\":\"Daily Digest\",\"allowedSenders\":[\"news@example.com\"]}'\n```\n\n## Installation\n\nSee **[INSTALL.md](INSTALL.md)** for the full setup, deployment, and configuration guide. Quick start:\n\n```bash\nnpx wrangler login\nbash setup.sh        # prompts for admin password + domain, provisions KV, generates wrangler.toml\nnpm run deploy       # deploys the Worker and registers your custom domain\n```\n\nThen 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).\n\n## Security notes\n\n- When using Option B (ForwardEmail), inbound webhook access is IP-restricted to ForwardEmail MX sources.\n- Admin auth uses a signed, `HttpOnly`, `Secure`, `SameSite=Strict` cookie.\n- Admin responses are `no-store` to avoid cache leakage.\n- 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.\n- For high-value feeds, set `Allowed senders` so only known sender addresses/domains are accepted.\n- You should use a strong admin password and rotate periodically.\n- All secret comparisons (admin password, proxy secret) use constant-time comparison to prevent timing attacks.\n\n## Acknowledgements\n\n- [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).\n- [Email-to-RSS](https://github.com/yl8976/Email-to-RSS) by yl8976 — the initial codebase this project is based on.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjuherr%2Fkill-the-news","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjuherr%2Fkill-the-news","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjuherr%2Fkill-the-news/lists"}