105 Commits
Author SHA1 Message Date
Julien HerrandClaude Sonnet 4.6 24c7d2a53e fix(release): create release before uploading bundle
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:50:39 +02:00
Julien HerrandClaude Sonnet 4.6 7e72022ffc fix(release): remove redundant cp and use env var for bundle path
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:49:25 +02:00
Julien HerrandClaude Sonnet 4.6 78117421fd fix(build): add wrangler.build.toml for CI and fix autoconfig
Wrangler 4.94+ introduced --experimental-autoconfig (default: true) which
fails in non-interactive CI environments. Without a committed wrangler.toml,
the release action build was broken.

- Add wrangler.build.toml with minimal config (placeholder KV ID, no secrets)
- Update build script to use wrangler.build.toml + --no-experimental-autoconfig

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:47:48 +02:00
Julien HerrandClaude Sonnet 4.6 1b31665b4f docs: update WAF rate limiting instructions for free tier accuracy
- Fix menu path: Security → Security rules (not Security → WAF)
- Add free tier limitations note: 1 rule max, 10s period/block cap
- Show recommended vs free tier limits side by side in table
- Remove HTTP method filter from conditions (not available in rate limiting rules)
- Note Terraform supports method filtering and longer periods (paid plan)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:38:41 +02:00
Julien HerrandClaude Sonnet 4.6 75a557d542 feat(admin): add site header and footer to all admin pages
Adds a minimal header with a branded link to kill-the.news and an
"admin" badge, plus a discreet footer with site link and GitHub
Sponsors link.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:23:48 +02:00
Julien HerrandClaude Sonnet 4.5 4a4c276859 feat: add sender blocklist with priority matching and quick-add dropdown
- Add `blocked_senders` field to FeedConfig (alongside existing `allowed_senders`)
- Refactor sender matching to priority-based logic: exact block > exact allow > domain block > domain allow, enabling exceptions (e.g. allow [email protected] despite blocking gmail.com)
- Add `POST /admin/feeds/:feedId/sender-filter` endpoint for quick allow/block from email detail view; returns 409 on conflict with opposite list
- Add ⋮ dropdown on From field in email detail with 4 options (allow/block sender/domain), inline success/error feedback
- Add blocked_senders textarea to create/edit feed forms
- 209 tests passing

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-22 23:09:53 +02:00
Julien HerrandClaude Sonnet 4.6 7b2b98d693 refactor: extract url helpers, add EMAIL_DOMAIN support
- Add src/utils/urls.ts with baseUrl, feedRssUrl, feedAtomUrl, feedUrl,
  feedEmailAddress, feedTopicPattern
- Add optional EMAIL_DOMAIN env var so web domain and email domain can
  differ (e.g. demo.kill-the.news serves feeds, @kill-the.news receives mail)
- Replace all inline domain template literals with the new helpers
- Remove unused site_url/feed_url fields from FeedConfig
- Remove unused feedPath param from fetchFeedData
- Extract verifyCallback() to deduplicate verifyAndStoreSubscription /
  verifyAndDeleteSubscription

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 22:38:29 +02:00
Julien HerrandClaude Sonnet 4.6 c64ceb4ee7 feat(site): add GitHub Sponsors links (nav, sponsor section, footer)
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 21:56:56 +02:00
Julien HerrandClaude Sonnet 4.6 6bf5ae0356 feat: landing page install guide, demo banner, WAF docs, nightly demo reset
- docs/index.html: nav links (Features/How it works/Install), hero CTAs
  (Try demo primary, Self-host, GitHub), demo banner with credentials,
  full 7-step installation section with WAF rate limiting guide (dashboard
  + Terraform) integrated as step 7
- wrangler-example.toml: cron trigger on demo env for nightly KV reset at 03:00 UTC
- src/index.ts: scheduled handler that wipes all EMAIL_STORAGE KV keys
- TODO.md: mark WAF rate limiting as done

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 21:50:42 +02:00
Julien Herr af721c081a chore: mark WebSub as done in TODO.md 2026-05-22 21:14:00 +02:00
Julien HerrandClaude Sonnet 4.5 a29e9ab372 feat: WebSub Atom support, HTML processing via linkedom, W3C badges
WebSub / PubSubHubbub:
- Hub now accepts both /rss/:id and /atom/:id topic URLs
- WebSubSubscription stores format ("rss" | "atom")
- notifySubscribers sends RSS or Atom XML with correct Content-Type
- verifyAndStoreSubscription sends correct topic URL per format
- CI paths-ignore docs/** to skip deploy on docs-only changes

HTML processing (linkedom + escape-html):
- New html-processor.ts: body extraction, script/iframe/object removal,
  event handler + javascript: URL stripping, mso-* style cleanup,
  plain text → <pre> with HTML escaping via escape-html
- feed-generator.ts and entries.ts use processEmailContent

Admin UI:
- W3C validation badges (Atom + RSS) on feed detail page

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-22 21:12:22 +02:00
Julien HerrandClaude Sonnet 4.6 1789870f27 fix(feed): use permalink URL as Atom entry id, strip mso-* inline styles
- Entry <id> was a non-URL string (timestamp + base64 snippet), which
  is invalid per the Atom spec; now uses the entry permalink URL which
  is both valid and stable across feed regeneration
- Strip mso-* properties from inline style attributes in extracted body
  content to eliminate the feed validator DangerousStyleAttr warning
  caused by Microsoft Office HTML in newsletter emails

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 21:12:22 +02:00
Julien HerrandClaude Sonnet 4.6 afed4464cf fix(feed): use permalink URL as Atom entry id, strip mso-* inline styles
- Entry <id> was a non-URL string (timestamp + base64 snippet), which
  is invalid per the Atom spec; now uses the entry permalink URL which
  is both valid and stable across feed regeneration
- Strip mso-* properties from inline style attributes in extracted body
  content to eliminate the feed validator DangerousStyleAttr warning
  caused by Microsoft Office HTML in newsletter emails

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:43:06 +02:00
Julien HerrandClaude Sonnet 4.6 4428f35dd4 fix(feed): add Atom link in emails page, fix HTML stripping, use request URL for self-link
- Add Atom Feed URL to the Feed Details card in the emails page
- Fix extractBodyContent to handle emails without a closing </body> tag
  (regex now falls back to capturing everything after the opening <body>)
- Use the actual request URL origin for atom:link rel="self" in RSS/Atom
  feeds, guaranteeing it always matches the document location regardless
  of how DOMAIN is configured

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:41:21 +02:00
Julien HerrandClaude Sonnet 4.6 bcc9640591 fix(feed): correct feed link, canonical id, and strip html wrapper from content
- link: computed as /admin/feeds/:id/emails instead of stale site_url from KV
- id: computed dynamically from baseUrl instead of stale feed_url from KV
- item description/content: strip <html><head><body> wrapper via extractBodyContent()
  so feed readers receive a body fragment, not a full HTML document

Fixes RSS validator warnings: SelfDoesntMatchLocation (stale KV domain) and
InvalidHTML (full HTML document inside <description>/<content:encoded>).
Adds 8 tests covering extractBodyContent and the new feed/atom link assertions.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:28:13 +02:00
Julien HerrandClaude Sonnet 4.6 780bf6c190 fix(admin): add Atom feed URL, unify container width, update branding
- Atom feed URL shown in both list and table views (new Atom column)
- Remove container-wide toggle — both views now use max-width 1200px
- Update dashboard title and login title to kill-the-news

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:21:49 +02:00
Julien HerrandClaude Sonnet 4.6 41f70143d1 design(admin): add Inter font, update page title to kill-the-news
Imports Inter 400/500/600/700 from Google Fonts to match the landing
page typography. Updates browser tab title format.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:21:42 +02:00
Julien HerrandClaude Sonnet 4.6 b95fbb468f design(admin): align with kill-the-news landing page visual identity
- variables.css: orange primary (#f6821f), dark bg (#0a0a0a), Inter font
- layout.css: orange radial glow, unified container 1200px (no width jump)
- components.css: orange buttons, remove backdrop-filter on inputs/cards

Fixes blurred form fields (double backdrop-filter), jarring width shift
between list/table views, and mismatched blue iOS aesthetic vs orange
Cloudflare identity of the site.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:21:34 +02:00
Julien HerrandClaude Sonnet 4.6 ba7add5f53 fix(demo): correct DOMAIN env var to demo.kill-the.news
The [env.demo] section pointed DOMAIN to kill-the.news while the
custom_domain route was demo.kill-the.news, causing feed/email URLs
to show the wrong domain in the admin UI.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:21:26 +02:00
Julien Herr 3ccbd876b6 build(deps-dev): bump dev-dependencies group with 8 updates
Includes TypeScript 5→6 migration: moduleResolution node→bundler.
2026-05-22 17:35:01 +02:00
Julien Herr 03d069bfa3 build(deps): bump hono from 4.11.7 to 4.12.22
Fix TypeScript errors from stricter c.req.param() types (string | undefined).
2026-05-22 17:33:47 +02:00
Julien Herr ea400883dd build(deps): bump zod from 4.3.6 to 4.4.3 2026-05-22 17:32:50 +02:00
Julien Herr 7dd1225cde build(deps): bump feed from 5.2.0 to 5.2.1 2026-05-22 17:32:37 +02:00
Julien Herr 3b4a1ce98c build(deps): bump actions/setup-node from v4 to v6.4.0 2026-05-22 17:32:19 +02:00
Julien Herr 3fa17228a3 build(deps): bump actions/checkout from v4 to v6.0.2 2026-05-22 17:32:04 +02:00
Julien Herr ebfb8d4a60 fix(config): silence CSS module rule fallthrough warning 2026-05-22 17:28:02 +02:00
Julien Herr d657875322 feat(ci): auto-deploy demo instance on main push 2026-05-22 17:20:46 +02:00
Julien Herr 4d9418057e feat(deploy): add demo environment to wrangler config 2026-05-22 17:20:08 +02:00
Julien HerrandClaude Sonnet 4.6 edc1183e61 docs: rewrite CLAUDE.md to match actual codebase, remove .cursor
CLAUDE.md now reflects the real route set (atom, entries, files, hub,
email handler), src/lib/ layout, admin sub-modules, client script
pipeline, full Env bindings, and WebSub KV schema.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 15:53:22 +02:00
Julien HerrandClaude Sonnet 4.6 4316354ee5 refactor(styles): migrate TS template literal styles to real CSS files
Extract CSS from TypeScript template literals into standalone .css files
(variables.css, layout.css, components.css, utilities.css) and update
src/routes/admin/ui.tsx to import them directly via Wrangler text imports,
concatenating the strings at runtime for the inline <style> tag.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 15:44:08 +02:00
Julien HerrandClaude Sonnet 4.6 5d75682702 build: add Wrangler text rule for CSS imports
Add [[rules]] type = "Text" globs = ["**/*.css"] to wrangler-example.toml
so Wrangler bundles .css files as raw text strings importable in TypeScript.
Add src/types/css.d.ts to provide the module declaration for `import css from "*.css"`.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 15:43:49 +02:00
Julien HerrandClaude Sonnet 4.5 d64f703820 fix(ui): replace children cast to any with hono/jsx Child type
Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-22 15:35:10 +02:00
Julien HerrandClaude Sonnet 4.5 0e1592f8c6 build: gitignore generated client scripts, rebuild on npm install
Move src/scripts/generated/ to .gitignore — files are deterministic
build artefacts and don't belong in version control. Wire build:client
into prepare so they're regenerated automatically after npm install.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-22 13:53:33 +02:00
Julien HerrandClaude Sonnet 4.6 0f6670d0e9 refactor(admin): extract emailsScript to src/scripts/client/emails-page.ts
Moves the inline JS template literal from emails.tsx into a typed
TypeScript source file. The dynamic feedId value (previously interpolated
directly) is now passed via a window.__APP_CONFIG__ bootstrap script
injected immediately before the compiled static script in the HTML.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:50:54 +02:00
Julien HerrandClaude Sonnet 4.6 40f4b42cd5 refactor(admin): extract dashboardScript to src/scripts/client/dashboard.ts
Moves the 650-line inline JS template literal from admin.tsx into a
proper TypeScript source file with full type annotations. esbuild
compiles it to a minified IIFE committed in src/scripts/generated/,
which is imported and inlined into the HTML response as before.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:50:47 +02:00
Julien HerrandClaude Sonnet 4.6 0663861471 build: add client-side TypeScript compilation pipeline
Adds scripts/build-client.mjs which uses esbuild to compile TypeScript
files in src/scripts/client/ into minified IIFE bundles, then writes
them as TypeScript string-constant modules in src/scripts/generated/.

- Adds build:client npm script; wires it as prebuild and predev hooks
- Adds src/scripts/client/tsconfig.json with DOM lib for IDE support
- Excludes src/scripts/client/ from the root Worker tsconfig to avoid
  DOM type conflicts with the Cloudflare Workers runtime types

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:50:41 +02:00
Julien HerrandClaude Sonnet 4.6 671179acc7 build: add esbuild dev dependency
Installs esbuild to support compiling client-side TypeScript into
minified IIFE bundles that can be inlined into Worker HTML responses.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:50:31 +02:00
Julien HerrandClaude Sonnet 4.6 9bfebf4aa4 refactor(admin): migrate admin.ts to admin.tsx with JSX login and dashboard
Convert login page and dashboard GET routes from hono/html tagged template
literals to typed JSX using the <Layout> component. Extracts reusable
CopyIcon, CheckIcon, and CopyFieldInline components. Dashboard inline
script (~650 lines) preserved exactly via dangerouslySetInnerHTML constant.
All auth logic, CSRF middleware, and API routes are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:17:46 +02:00
Julien HerrandClaude Sonnet 4.6 c9ab3839e4 refactor(admin): migrate emails.ts to emails.tsx with JSX rendering
Convert feed emails list and single email view GET routes from hono/html
tagged template literals to typed JSX. Extracts reusable CopyField and
SVG icon components. Inline page scripts are preserved verbatim via
dangerouslySetInnerHTML. Raw HTML display in single email view uses
dangerouslySetInnerHTML to avoid double-escaping pre-escaped content.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:17:38 +02:00
Julien HerrandClaude Sonnet 4.6 ecb85730e0 refactor(admin): migrate feeds.ts to feeds.tsx with JSX rendering
Convert the edit feed GET route from hono/html tagged template literals
to typed JSX using the <Layout> component. All CRUD routes and business
logic are preserved unchanged. textarea placeholder special characters
are now handled via JSX attribute escaping rather than &#10; entities.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:17:31 +02:00
Julien HerrandClaude Sonnet 4.6 996aa5e211 refactor(admin): migrate ui.ts to ui.tsx with JSX Layout component
Replace hono/html tagged template layout() function with a typed JSX
<Layout> component. CSS and interactive scripts are injected via
dangerouslySetInnerHTML to preserve exact output. clampText() is
preserved and re-exported for consumers.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:17:24 +02:00
Julien HerrandClaude Sonnet 4.6 f5bac1bd7d build: add hono/jsx support to tsconfig
Add jsx and jsxImportSource compiler options to enable hono/jsx server-side
rendering in .tsx files without per-file JSX pragmas.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 13:17:16 +02:00
Julien HerrandClaude Sonnet 4.6 d82344ac67 chore: remove TECH_DEBT.md — all tasks completed
All three phases fully remediated. P1-5 (WAF rate limiting) is
infrastructure-only and tracked in TODO.md.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 12:01:42 +02:00
Julien HerrandClaude Sonnet 4.6 427eac973c docs: mark Phase 3 tasks done; document WAF rate limiting in TODO
P1-4, P2-10, P2-11 marked DONE in TECH_DEBT.md.
P1-5 (rate limiting) is infrastructure-only — documented in TODO.md with
the recommended Cloudflare WAF rule targets and thresholds.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 10:56:55 +02:00
Julien HerrandClaude Sonnet 4.6 205d4ef5bb refactor: split admin.ts into sub-modules (P2-11)
Extracts 3833-line admin.ts into focused modules:
- src/routes/admin/ui.ts       — layout() and clampText() helpers
- src/routes/admin/helpers.ts  — KV list helpers (listAllFeeds, addFeedToList, etc.)
- src/routes/admin/feeds.ts    — feed CRUD routes (feedsRouter)
- src/routes/admin/emails.ts   — email view/delete routes (emailsRouter)

admin.ts now mounts the sub-routers and retains only auth middleware,
dashboard, login/logout, and the in-place feed API update route.
All 163 tests continue to pass.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 10:56:20 +02:00
Julien HerrandClaude Sonnet 4.6 a9501d6e44 feat: add structured JSON logger and worker utility (P1-4)
Introduces src/lib/logger.ts emitting JSON lines (level, message, data)
compatible with Cloudflare Logpush. Replaces all console.log/warn/error
calls in email-processor.ts, index.ts, and hub.ts with structured logger
calls. Extracts waitUntilSafe into src/utils/worker.ts to avoid duplicating
the executionCtx guard across routes.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 10:55:15 +02:00
Julien HerrandClaude Sonnet 4.6 0c0669c473 feat: extract constants module (P2-10)
Centralises magic numbers and string literals into src/config/constants.ts
(FEED_MAX_BYTES, MAX_FEED_ITEMS, MAX_METADATA_EMAILS, ADMIN_COOKIE_MAX_AGE,
FORWARD_EMAIL_IPS_CACHE_TTL_MS, DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS,
FEEDS_LIST_KEY). Consumers updated in storage.ts and feed-fetcher.ts.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 10:55:01 +02:00
Julien HerrandClaude Sonnet 4.6 582108a8e3 docs: move Durable Objects migration from TECH_DEBT to TODO
The race condition is an accepted limitation of KV; the DO migration
is a future feature rather than active tech debt to remediate.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 10:31:41 +02:00
Julien HerrandClaude Sonnet 4.6 a0415cdc41 refactor: replace custom HMAC CSRF with hono/csrf middleware
Removes 38-line hand-rolled HMAC-SHA256 implementation in favour of
the built-in hono/csrf, which validates the Origin header natively.

- Delete src/utils/csrf.ts
- Replace custom CSRF middleware with hono/csrf (Origin-header check)
- Remove csrfToken from ContextVariableMap, layout(), forms, and JS fetch() calls
- Update admin tests: swap X-CSRF-Token for Origin header

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 10:28:26 +02:00
Julien HerrandClaude Sonnet 4.6 7d375693b9 feat: complete Phase 2 tech debt remediation
- Extract shared RSS/Atom fetch logic into feed-fetcher utility (P1-3)
- Split email-processor into validateEmail/storeEmail functions (P1-6)
- Add stateless HMAC-SHA256 CSRF protection to admin forms (P2-8)
- Fix Hono<{ Bindings: Env }> type safety across all routes (P3-13)
- Add entries.test.ts and files.test.ts with full coverage (P1-7)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 09:46:55 +02:00
Julien Herr f2981eec31 test: add inbound route tests covering IP auth, validation, and R2 upload 2026-05-22 07:39:48 +02:00
Julien Herr e874906291 docs: inline Durable Objects migration TODO in email-processor 2026-05-22 07:39:43 +02:00
Julien Herr 10404efffa fix: delete orphaned KV entries when trimming feed metadata 2026-05-22 07:39:37 +02:00
Julien Herr dae5db2524 feat: add GET /health endpoint 2026-05-22 07:39:24 +02:00
Julien HerrandClaude Sonnet 4.6 bde06dd3e4 fix(websub): add missing WebSub Link header to Atom feed
The RSS feed already advertised hub and self via the Link response
header, but the Atom feed was missing it entirely. Subscribers using
Atom had no way to discover the hub for real-time push notifications.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 23:46:51 +02:00
Julien HerrandClaude Sonnet 4.6 5723fd36f9 refactor(admin): validate JSON feed update via @hono/zod-validator
Moves validation of POST /api/feeds/:feedId/update from inline
schema.parse() to zValidator middleware. The route now receives
typed validated data via c.req.valid("json"), and returns a
structured {success: false, error: ZodIssue[]} on invalid input.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 23:46:51 +02:00
Julien HerrandClaude Sonnet 4.6 57e0cc5413 refactor(cors): replace manual CORS middleware with hono/cors
Fixes a bug where routes returning raw `new Response()` (RSS, Atom,
entries) were not receiving CORS headers — hono/cors applies headers
after next(), covering all response paths.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 23:46:51 +02:00
Julien Herr ece237c0af chore: ignore coverage/ directory 2026-05-21 23:46:51 +02:00
Julien HerrandClaude Sonnet 4.6 68151cbb5f fix(websub): require feed existence for subscriptions, remove atom hub header, simplify router mounting
- Add KV feed existence check in hub.ts to prevent SSRF via non-existent feeds (returns 404)
- Treat empty string hub.secret as absent (|| instead of ??)
- Remove misleading hub Link header from atom.ts (hub only supports RSS topics)
- Simplify double-layered hub router in index.ts (direct app.route instead of nested Hono)
- Update hub.test.ts to seed KV with feed config for tests requiring valid subscribe/unsubscribe

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 23:46:50 +02:00
Julien HerrandClaude Sonnet 4.6 0d00e003d4 test(websub): add ctx.waitUntil coverage for processEmail notification wiring
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 23:46:50 +02:00
Julien HerrandClaude Sonnet 4.6 d0764ddd8e feat(websub): wire real-time push notifications on email ingest
Pass ExecutionContext through the email processing chain so notifySubscribers
is called via ctx.waitUntil after a new email is stored.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 23:46:50 +02:00
Julien Herr 6d221a07dd feat(websub): add hub discovery Link headers to RSS and Atom feeds 2026-05-21 23:46:50 +02:00
Julien Herr aa41337c6b feat(websub): mount /hub route 2026-05-21 23:46:50 +02:00
Julien HerrandClaude Sonnet 4.6 09db52bb4d test(websub): add hub route tests
Add comprehensive tests for POST /hub validation (missing fields, unknown mode, non-HTTPS callback, invalid URL, wrong domain, secret > 200 bytes) and happy-path subscribe/unsubscribe (202). Also fix hub.ts to use a waitUntilSafe wrapper so executionCtx.waitUntil doesn't throw in Node test environments.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 23:46:50 +02:00
Julien Herr 4165774667 fix(websub): validate callback URL (HTTPS), fix domain regex, enforce secret length 2026-05-21 23:46:50 +02:00
Julien Herr ee4b9d8fdc feat(websub): add hub route (subscribe/unsubscribe) 2026-05-21 23:46:49 +02:00
Julien Herr e8f5af8b87 test(websub): add missing error-path tests for verify functions 2026-05-21 23:46:49 +02:00
Julien Herr c6785554d4 test(websub): add unit tests for WebSub utilities 2026-05-21 23:46:49 +02:00
Julien Herr 0b4ed0fbec fix(websub): fix signature header, add delivery logging, parallelize KV reads 2026-05-21 23:46:49 +02:00
Julien Herr 8810109e6f feat(websub): add WebSub utilities (HMAC, KV helpers, notify, verify) 2026-05-21 23:46:49 +02:00
Julien Herr ed6d2b4a0c feat(websub): add WebSubSubscription type 2026-05-21 23:46:49 +02:00
Julien Herr 20dc20ed1d Update FUNDING.yml to include GitHub sponsor 2026-05-21 21:44:13 +02:00
Julien HerrandClaude Sonnet 4.6 6fddbd40f2 feat: rename project to kill-the-news, add GitHub Pages landing page
- Rename all references from Email-to-RSS/email-to-rss to kill-the-news
  across README.md, AGENTS.md, package.json, wrangler-example.toml, setup.sh
- Add docs/index.html: dark-themed landing page for GitHub Pages
  covering features, how it works, quick start, and tech stack

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 20:45:25 +02:00
Julien Herr aede0c9d16 Merge pull request #1 from juherr/feat/add-eslint-lint-staged
feat: add ESLint, lint-staged, and update pre-commit hook + CI
2026-05-21 20:41:15 +02:00
Julien Herr b0c31ac8ef Create CNAME 2026-05-21 20:32:22 +02:00
Julien HerrandClaude Sonnet 4.6 b26990a875 fix: address PR review comments
- Fix KV json overload to return Promise<unknown | null> (null on missing keys)
- Add shebang to Husky pre-commit hook
- Explicitly add eslint ^10.0.0 to devDependencies

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 12:09:26 +02:00
Julien HerrandClaude Sonnet 4.6 b24ee969d1 style: fix Prettier formatting on 11 files
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 11:35:37 +02:00
Julien HerrandClaude Sonnet 4.6 3aea41f862 feat: add ESLint, lint-staged, and update pre-commit hook + CI
- Add ESLint 9 flat config (eslint.config.mjs) with typescript-eslint
  recommended rules and eslint-config-prettier
- Add lint-staged to run eslint+prettier only on staged files
- Update pre-commit hook to use lint-staged instead of full prettier check
- Add `lint` and `format:check` scripts to package.json
- Add Lint step to CI workflow
- Fix resulting lint errors: unused vars (_ctx, _options, catch binding),
  any→unknown in type declarations, stale eslint-disable comments

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 09:49:20 +02:00
Julien HerrandClaude Sonnet 4.6 e93bbb8d3e feat: store email attachments in R2 and expose as RSS enclosures
Attachments from incoming emails are uploaded to an optional Cloudflare R2
bucket and exposed as <enclosure> elements in RSS and <link rel="enclosure">
in Atom feeds, served at /files/{id}/{filename} with immutable caching.

R2 is opt-in: if ATTACHMENT_BUCKET is not bound the feature is a no-op.
Attachments are cleaned up from R2 on email/feed deletion and during
size-based feed trimming. Adds MockR2 to the test setup.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 09:09:37 +02:00
Julien Herr 3e28246c61 docs: document FEED_MAX_SIZE_BYTES and proxy auth setup in README 2026-05-21 08:42:48 +02:00
Julien HerrandClaude Sonnet 4.6 caaa6a7ba6 feat: add external proxy auth support (Authelia/Authentik)
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 08:39:10 +02:00
Julien HerrandClaude Sonnet 4.6 9eba4c34c6 feat: replace fixed 50-entry cap with size-based feed trimming
Emails are now trimmed from the oldest end when total serialised size
exceeds FEED_MAX_SIZE_BYTES (default 512 KB). Each EmailMetadata entry
stores its size so future trims are computed without re-reading KV.
Adds FEED_MAX_SIZE_BYTES, PROXY_TRUSTED_IPS and PROXY_AUTH_SECRET to Env.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 08:28:07 +02:00
Julien HerrandClaude Sonnet 4.6 984362f637 docs: add CLAUDE.md with project guidance for Claude Code
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 07:40:00 +02:00
Julien Herr 19b590a568 docs: mark Atom feed format as done in TODO 2026-05-21 07:36:14 +02:00
Julien Herr 1acc2f952c feat: mount /atom route in main app 2026-05-21 07:36:05 +02:00
Julien Herr b107803177 feat: add Atom 1.0 feed route at /atom/:feedId 2026-05-21 07:35:32 +02:00
Julien Herr 4d8d1fdc82 refactor: extract buildFeed helper, add generateAtomFeed 2026-05-21 07:34:29 +02:00
Julien HerrandClaude Sonnet 4.6 ad5bd9a79a docs: document Cloudflare Email Workers as primary ingestion method
Add setup instructions for Cloudflare Email Routing (Option A, recommended)
and reposition ForwardEmail as an optional alternative (Option B). Clarify
Cloudflare free plan includes Workers, KV, and Email Routing. Fix security
note scope and remove redundant/misleading content.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 00:36:44 +02:00
Julien HerrandClaude Sonnet 4.6 1c8b0ca194 ci: add CI workflow for format, typecheck, and test
Runs on push/PR to main. Also adds skipLibCheck to tsconfig to silence
pre-existing errors in vite/vitest declaration files that don't affect
the Worker build.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 00:07:25 +02:00
Julien HerrandClaude Sonnet 4.6 2e9cdd4364 test: add unit tests for EmailParser
Covers extractFeedId, decodeEncodedWords, and parseForwardEmailPayload
including edge cases: missing fields, structured vs text from, headerLines
vs raw headers string, RFC 2047 subject decoding.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 00:06:13 +02:00
Julien HerrandClaude Sonnet 4.6 41efee44ca refactor: replace custom escapeHtml with Hono's html template
Hono's `html` tagged template auto-escapes all interpolated values;
`raw()` is used for the email body which must render as HTML.
This removes the ad-hoc utility and aligns entries.ts with the
same pattern already used in admin.ts.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-21 00:05:35 +02:00
Julien HerrandClaude Sonnet 4.6 5308544672 refactor: simplify quick-win code after review
- Make feedId required in generateRssFeed (removes dead /emails/ fallback)
- Hoist loop-invariant conditional and remove intermediate variable
- Extract normalizeAllowedSenders() so JSON and form paths share same logic
- Move escapeHtml to src/utils/html.ts for reuse by admin.ts
- Parallelize the two independent KV puts in feed creation

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-20 23:55:17 +02:00
Julien HerrandClaude Sonnet 4.6 e140b433d8 docs: mark quick wins as done in TODO.md
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-20 23:52:26 +02:00
Julien HerrandClaude Sonnet 4.6 fdedbe13c4 feat: accept JSON on POST /admin/feeds/create and return {feedId, email, feedUrl}
When Content-Type is application/json, parse the request body as JSON and
return a JSON response instead of redirecting. Useful for automation tools
(e.g. Terraform/OpenTofu provisioning).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-20 23:52:14 +02:00
Julien HerrandClaude Sonnet 4.6 298765527c feat: add HTML view for individual email entries at /entries/:feedId/:receivedAt
Serves each email as a standalone HTML page with a Content-Security-Policy
header, useful for reading emails outside a feed reader and for debugging.
Also updates RSS item links to point to this route.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-20 23:51:28 +02:00
Julien HerrandClaude Sonnet 4.6 54e7a1bfa0 feat: parse <author> from From header in RSS items
Parse the From header into name + email parts so the feed library
renders proper RFC 2822 format (email (Name)) in <author> elements.
Also passes feedId to the generator so item links can point to the
upcoming /entries/:feedId/:receivedAt route.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-20 23:50:54 +02:00
Julien HerrandClaude Sonnet 4.6 6a6614fb26 docs: add TODO.md with feature gaps vs kill-the-newsletter
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-20 23:44:44 +02:00
Julien HerrandClaude Sonnet 4.6 da6494cbd8 ci: pin action versions to SHA + add github-actions to Dependabot
Dependabot will now open PRs when new versions of actions/checkout and
actions/setup-node are released, keeping the pinned SHAs up to date.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-20 23:34:59 +02:00
Julien HerrandClaude Sonnet 4.6 5694498d89 ci: publish dist/index.js to GitHub Release on version tag
Triggers on v* tags. Builds with wrangler --dry-run, falls back to
dist/worker.js and dist/_worker.js if index.js is not produced.
Uploads as index.js so homelab Tofu can fetch a stable URL.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-20 23:28:47 +02:00
Julien HerrandClaude Sonnet 4.6 50b1911854 chore: add Dependabot config for weekly npm updates
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-20 23:15:18 +02:00
Julien HerrandClaude Sonnet 4.6 243eec0cce refactor: extract ForwardEmail adapter to src/lib/forwardemail.ts
Mirrors the same pattern as cloudflare-email.ts — each provider has its
own adapter that translates provider-specific input into ProcessEmailInput
and delegates to processEmail(). inbound.ts is now a thin HTTP handler.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-20 23:13:43 +02:00
Julien HerrandClaude Sonnet 4.6 71a5c20e62 refactor: parallelize KV writes, cap metadata, remove redundant normalization
- email-processor: run email put + metadata get in parallel (saves one KV round-trip per email)
- email-processor: add missing 50-email metadata cap (was unbounded unlike storage.ts)
- email-processor: remove redundant normalizeEmail call on already-normalized allowedSender
- email-parser: strip WHAT-comments and boilerplate JSDoc throughout

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-20 23:05:26 +02:00
Julien HerrandClaude Sonnet 4.5 093efe7fc9 feat: add Cloudflare Email Workers support alongside ForwardEmail
Both email providers now work in parallel on the same Worker:
- ForwardEmail: existing POST /api/inbound webhook (unchanged)
- Cloudflare Email Routing: native `email` handler using postal-mime

New files:
- src/lib/email-processor.ts  shared business logic (feed lookup,
  sender allowlist, KV storage) extracted from inbound.ts
- src/lib/cloudflare-email.ts  Cloudflare `email` handler; parses
  raw RFC 2822 email with postal-mime, delegates to processEmail()
- src/lib/email-processor.test.ts  9 unit tests
- src/lib/cloudflare-email.test.ts  5 integration tests

Also fixes pre-existing CORS 204 response: c.text("", 204) →
c.body(null, 204) to match Hono's EmptyStatusCode constraint.

To enable: configure Cloudflare Email Routing with a catch-all rule
`*@domain.com` pointing to this Worker.

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-20 22:54:46 +02:00
Julien HerrandClaude Sonnet 4.5 29446a2aac chore: add typecheck script and fix pre-existing TypeScript errors
- Add `typecheck` script (`tsc --noEmit`) to package.json
- Remove conflicting `declare global` from test/setup.ts (superseded
  by @cloudflare/workers-types); use `globalThis as any` for test globals
  and declare minimal `require` locally to avoid pulling in @types/node
- Cast `createMockEnv()` and `deleteRes.json()` results in admin.test.ts
  to silence strict `unknown` / MockKV-vs-KVNamespace errors

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-20 22:54:32 +02:00
Julien HerrandClaude Sonnet 4.5 3ed9d2ee22 chore: apply Prettier formatting to entire codebase
Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-20 22:01:53 +02:00
84 changed files with 15593 additions and 6626 deletions

No files matched your search

@@ -1,57 +0,0 @@
---
description: Project Implementation Plan
globs: *
alwaysApply: false
---
# Detailed Project Implementation Plan
This section is a top-level "blueprint" describing the solution architecture and how each component fits together.
## Overview
**Goal:** Build a service that turns email newsletters into RSS feeds, so you can subscribe in an RSS reader like Reeder. The service should provide unique email addresses per feed, a front-end admin panel, indefinite (or long-term) storage of newsletters, and minimal cost—preferably using Cloudflare services plus ForwardEmail.net.
## Key Components
1. **ForwardEmail.net**
- Accept incoming newsletters on your custom domain’s email addresses.
- Forward them (via webhook) to your API endpoint for processing.
- Free inbound plan includes JSON + raw MIME data.
2. **Cloudflare Workers**
- **Inbound Worker:** Receives the webhook from ForwardEmail.net, parses/stores newsletter data in KV (or R2).
- **RSS Worker:** Serves RSS feeds by reading from KV and outputting XML.
- **Admin Worker (potential):** Could serve a small UI or JSON API for feed management.
3. **Cloudflare KV**
- Key-value store for storing newsletter items (subject, date, HTML, etc.).
- Minimal cost for text data.
- Indefinite retention if you keep usage under limits.
4. **Cloudflare Pages (Optional)**
- Could host a separate front-end for admin tasks.
- Alternatively, build a simple admin UI directly within the Worker.
5. **Admin Dashboard**
- Basic login and feed creation (generate random email addresses).
- List newsletters and optionally delete them or rename feed titles.
- For a simple approach, implement a minimal password-protected area or JSON endpoints.
6. **Domain / DNS Setup**
- Use your custom domain (e.g. `mynewsletters.dev`).
- Add DNS records so ForwardEmail.net is the MX handler.
- Configure Cloudflare for general DNS (with “Orange Cloud” or not, depending on your proxying preferences).
- Verify your domain following ForwardEmail.net’s instructions.
## Data Flow
1. A newsletter arrives at `[email protected]`.
2. ForwardEmail.net triggers a webhook to `https://your-worker.example.com/api/inbound?feed=XYZ` with JSON + raw MIME.
3. The Worker parses the email, extracts relevant information (date, subject, HTML body), and stores it in KV under a key like `feed:XYZ:timestamp`.
4. When your RSS reader (e.g. Reeder) requests `GET https://your-worker.example.com/rss/XYZ`, the Worker fetches all items from KV for that feed, builds an RSS XML response, and returns it.
5. *(Optional)* The Admin Dashboard (via a password-protected route or a separate Cloudflare Pages front-end) can create new feed IDs, display items, etc.
## Summary of Implementation Steps
1. Set up the Domain and ForwardEmail.net for inbound mail.
2. Create a Cloudflare Worker to handle the inbound webhook.
3. Parse the email (using ForwardEmail.net’s parsed data or parsing the raw MIME if necessary).
4. Store the data in KV.
5. Create an RSS Worker endpoint to retrieve the data and output XML.
6. *(Optional)* Develop an Admin UI to create new feeds, list items, and manage them.
7. Deploy and test the solution. Subscribe to the feed with Reeder.
+1
View File
@@ -0,0 +1 @@
github: [juherr]
+14
View File
@@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
dev-dependencies:
dependency-type: "development"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
+37
View File
@@ -0,0 +1,37 @@
name: CI
on:
push:
branches: ["main"]
paths-ignore:
- "docs/**"
pull_request:
branches: ["main"]
paths-ignore:
- "docs/**"
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
- run: npm ci
- name: Format check
run: npm run format:check
- name: Lint
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Test
run: npm test
+48
View File
@@ -0,0 +1,48 @@
name: Deploy Demo
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]
jobs:
deploy-demo:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
- run: npm ci
- name: Generate wrangler.toml
env:
DEMO_KV_NAMESPACE_ID: ${{ secrets.DEMO_KV_NAMESPACE_ID }}
run: |
COMPAT_DATE=$(date +%Y-%m-%d)
sed \
-e "s/REPLACE_WITH_COMPATIBILITY_DATE/${COMPAT_DATE}/g" \
-e "s/REPLACE_WITH_YOUR_KV_NAMESPACE_ID/${DEMO_KV_NAMESPACE_ID}/g" \
-e "s/REPLACE_WITH_YOUR_PREVIEW_KV_NAMESPACE_ID/${DEMO_KV_NAMESPACE_ID}/g" \
-e "s/REPLACE_WITH_DEMO_KV_NAMESPACE_ID/${DEMO_KV_NAMESPACE_ID}/g" \
-e "s/REPLACE_WITH_YOUR_DOMAIN/kill-the.news/g" \
wrangler-example.toml > wrangler.toml
- name: Deploy to demo
run: npx wrangler deploy --env demo
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Set demo admin password
run: echo "$DEMO_ADMIN_PASSWORD" | npx wrangler secret put ADMIN_PASSWORD --env demo
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
DEMO_ADMIN_PASSWORD: ${{ secrets.DEMO_ADMIN_PASSWORD }}
+45
View File
@@ -0,0 +1,45 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- name: Locate bundled output
id: bundle
run: |
for f in dist/index.js dist/worker.js dist/_worker.js; do
if [ -f "$f" ]; then
echo "path=$f" >> "$GITHUB_OUTPUT"
exit 0
fi
done
echo "No bundled output found in dist/" >&2
exit 1
- name: Create GitHub Release and upload bundle
env:
GH_TOKEN: ${{ github.token }}
TAG_NAME: ${{ github.ref_name }}
BUNDLE_PATH: ${{ steps.bundle.outputs.path }}
run: |
gh release create "$TAG_NAME" --generate-notes --verify-tag || true
gh release upload "$TAG_NAME" "$BUNDLE_PATH" --clobber
+2
View File
@@ -4,8 +4,10 @@ yarn.lock
# Build # Build
dist/ dist/
coverage/
.wrangler/ .wrangler/
.dev.vars .dev.vars
src/scripts/generated/
# Environment # Environment
.env .env
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
npx lint-staged && npm run typecheck && npm test
-99
View File
@@ -1,99 +0,0 @@
# AGENTS.md
This file gives coding agents fast context for working in this repository.
## Project summary
Email-to-RSS is a Cloudflare Worker that ingests newsletters from ForwardEmail and exposes them as RSS feeds.
Core goals:
- Self-hosted and private
- Free-tier-friendly (Cloudflare + ForwardEmail)
- Minimal operational overhead
## Runtime and stack
- Runtime: Cloudflare Workers
- Framework: Hono (`src/index.ts` + `src/routes/*`)
- Storage: Cloudflare KV (`EMAIL_STORAGE` binding)
- Typescript + Vitest for development/testing
## Important files
- `setup.sh`: bootstraps local setup, KV namespaces, secrets, and local Wrangler config
- `wrangler-example.toml`: template used by setup
- `src/index.ts`: app boot + CORS + inbound IP allowlist middleware
- `src/routes/inbound.ts`: email ingestion endpoint
- `src/routes/rss.ts`: RSS rendering endpoint
- `src/routes/admin.ts`: admin UI and feed/email management
- `src/test/setup.ts`: test runtime mocks (KV + Cache)
## KV data model
Current keys used by routes:
- `feeds:list` -> `{ feeds: Array<{ id, title }> }`
- `feeds:list.feeds[].description` -> optional description (used to keep the dashboard fast; older data may omit it)
- `feed:<feedId>:config` -> feed config object
- `feed:<feedId>:config.allowed_senders` -> optional sender allowlist (email or domain)
- `feed:<feedId>:metadata` -> `{ emails: Array<{ key, subject, receivedAt }> }`
- `feed:<feedId>:<timestamp>` -> stored email body/metadata
Notes:
- Some utility files contain alternate key helpers not used by routes (`src/utils/storage.ts`).
- Keep route behavior and key schema consistent when refactoring.
## Setup/deploy workflow
1. `npx wrangler login`
2. `bash setup.sh`
3. Configure ForwardEmail DNS records in Cloudflare
4. `npm run deploy`
`setup.sh` assumes Wrangler v4 command syntax (`wrangler kv namespace ...`).
## Development workflow
- Install: `npm install`
- Test: `npm test`
- Build (dry-run deploy bundle): `npm run build`
- Dev server: `npm run dev`
## Testing notes
- Tests run in Node environment (`vitest.config.ts`), not DOM.
- Hono v4 test requests pass env as the 3rd arg: `app.request(path, init, env)`.
- Some tests intentionally hit validation errors; stderr logs are expected.
## Security assumptions
- Inbound endpoint only accepts requests from ForwardEmail source IPs.
- Admin access uses a signed cookie gate and password stored in Worker secret (`ADMIN_PASSWORD`).
- Admin pages set `Cache-Control: no-store`.
- Prefer setting `allowed_senders` on legitimate feeds to reduce inbound spam.
- Do not hardcode credentials or domain-specific secrets into tracked files.
## Spam cleanup workflow
- First choice: use dashboard bulk actions (`/admin`) with search + checkbox selection.
- Use **Table** view for bulk delete.
- Table columns are resizable and sortable; widths persist per-browser via localStorage.
- **Select Results** selects all rows currently shown by the search filter; **Clear Selection** unselects everything.
- Bulk deletes are performed asynchronously (batched requests) so the UI stays responsive.
- Avoid wildcard deletion; prefer search + small batches to reduce risk of deleting legitimate feeds.
## Cloudflare/Wrangler conventions
- `wrangler.toml` is generated locally from `wrangler-example.toml`.
- Keep `compatibility_date` current on meaningful runtime upgrades.
- Prefer explicit `--env production` for deploy/secret commands.
## If you change behavior
Update all of the following together:
- `README.md`
- `setup.sh` (if setup/deploy assumptions changed)
- tests under `src/routes/*.test.ts` and `src/test/setup.ts`
+144
View File
@@ -0,0 +1,144 @@
# CLAUDE.md
This file provides guidance to Claude Code when working in this repository.
## Commands
```bash
npm install # Install dependencies (also builds client scripts via prepare)
npm run dev # Start local dev server (wrangler dev)
npm test # Run all tests once
npm run test:watch # Run tests in watch mode
npm run test:coverage # Run tests with coverage report
npm run build # Dry-run deploy bundle (wrangler deploy --dry-run)
npm run build:client # Compile client scripts only (src/scripts/client → src/scripts/generated)
npm run deploy # Deploy to Cloudflare production
npm run format # Format with Prettier
```
Run a single test file:
```bash
npx vitest run src/routes/admin.test.ts
```
## Project summary
kill-the-news is a Cloudflare Worker that ingests email newsletters and exposes them as private RSS/Atom feeds. Self-hosted, free-tier-friendly (Cloudflare + ForwardEmail).
## Architecture
Single Cloudflare Worker built with Hono. Routes:
| Method | Path | Purpose |
| ------------------------------------ | ---------------------------------------------------------------------- | ------- |
| `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources |
| `GET /rss/:feedId` | Public RSS 2.0 feed |
| `GET /atom/:feedId` | Public Atom feed (with WebSub hub header) |
| `GET /entries/:feedId/:entryId` | Individual email HTML view |
| `GET /files/:attachmentId/:filename` | R2 attachment serving |
| `GET /admin` | Password-protected admin UI |
| `/hub` | WebSub hub (subscribe/publish) |
| `GET /health` | Health check |
| `email` | Cloudflare Email routing handler (alternative to ForwardEmail webhook) |
### Source layout
```
src/
index.ts # App entrypoint: CORS, IP middleware, route mounting, email handler export
config/constants.ts # Shared constants (TTLs, limits)
types/index.ts # Env, FeedConfig, EmailData, WebSubSubscription, etc.
routes/
inbound.ts # ForwardEmail webhook handler
rss.ts # RSS feed renderer
atom.ts # Atom feed renderer
entries.ts # Single email HTML view
files.ts # R2 attachment serving
hub.ts # WebSub hub
admin.tsx # Admin UI entrypoint (hono/jsx)
admin/ # Admin sub-modules
feeds.tsx # Feeds CRUD UI
emails.tsx # Emails list/delete UI
ui.tsx # Shared UI components
helpers.ts # Shared admin helpers
lib/
cloudflare-email.ts # Cloudflare Email routing handler
email-parser.ts # Email parsing (mailparser)
email-processor.ts # Core ingestion logic (parse → store)
feed-fetcher.ts # KV feed/email fetch helpers
feed-generator.ts # RSS/Atom XML generation
forwardemail.ts # ForwardEmail webhook types/parsing
id-generator.ts # Feed/entry ID generation
logger.ts # JSON structured logger
storage.ts # KV key helpers
websub.ts # WebSub subscription management
worker.ts # Typed worker export helper
scripts/
client/ # TypeScript client scripts (compiled by esbuild)
dashboard.ts # Admin dashboard interactions
emails-page.ts # Emails page interactions
generated/ # Compiled output (gitignored, rebuilt on npm install)
styles/ # CSS files bundled into the Worker
variables.css
layout.css
components.css
utilities.css
data/nouns.ts # Word list for ID generation
test/setup.ts # Test mocks: MockKV, createMockEnv()
```
### KV schema
All data lives in the `EMAIL_STORAGE` KV namespace:
| Key | Value |
| -------------------------------- | ------------------------------------------------------------------------ |
| `feeds:list` | `{ feeds: Array<{ id, title, description? }> }` |
| `feed:<feedId>:config` | `FeedConfig` |
| `feed:<feedId>:metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds? }> }` |
| `feed:<feedId>:<timestamp>` | Full `EmailData` |
| `websub:<feedId>:<callbackHash>` | `WebSubSubscription` |
`src/lib/storage.ts` contains key-builder helpers — use them; don't inline key strings in routes.
### Worker bindings (`Env`)
```ts
EMAIL_STORAGE: KVNamespace; // All feed/email data
ADMIN_PASSWORD: string; // Worker secret — never in config files
DOMAIN: string; // e.g. "getmynews.app"
ATTACHMENT_BUCKET?: R2Bucket; // R2 for email attachments
FEED_MAX_SIZE_BYTES?: string; // Optional email size cap
PROXY_TRUSTED_IPS?: string; // Trusted reverse-proxy IPs
PROXY_AUTH_SECRET?: string; // Shared secret for proxy auth
```
### Client scripts
`src/scripts/client/` contains TypeScript that runs in the browser. It is compiled by esbuild into `src/scripts/generated/` (gitignored) and bundled into the Worker as inline `<script>` tags. The `prepare` npm hook rebuilds them on `npm install`. Run `npm run build:client` to rebuild manually.
### Testing
Tests run in Node (not a Worker runtime). Hono test requests pass the mock env as the 3rd argument:
```ts
const res = await app.request("/path", init, createMockEnv());
```
MSW (`msw/node`) handles external HTTP mocks. Tests that hit validation paths intentionally produce stderr output — expected.
## Configuration
- `wrangler.toml` is generated locally from `wrangler-example.toml` by `setup.sh` — do not commit it
- `ADMIN_PASSWORD` is set via `wrangler secret put` — never in config files
- Keep `compatibility_date` current on runtime upgrades
## When changing behavior
Update together:
- `README.md`
- `AGENTS.md`
- `setup.sh` (if setup/deploy assumptions changed)
- Tests under `src/routes/*.test.ts` and `src/test/setup.ts`
+133 -24
View File
@@ -1,14 +1,14 @@
# Email-to-RSS # kill-the-news
Convert email newsletters into a private RSS feed using Cloudflare Workers + ForwardEmail. Convert email newsletters into private RSS feeds using Cloudflare Workers.
This project is self-hosted, uses your own domain, and keeps your data in your own Cloudflare account. 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 ## 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. 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.
Email-to-RSS keeps the same workflow while avoiding shared domains and shared data stores. kill-the-news keeps the same workflow while avoiding shared domains and shared data stores.
## Features ## Features
@@ -17,33 +17,50 @@ Email-to-RSS keeps the same workflow while avoiding shared domains and shared da
- Inline double-confirm delete interactions with toast feedback in the admin dashboard - Inline double-confirm delete interactions with toast feedback in the admin dashboard
- Resizable + sortable table columns in the admin dashboard (Table view) - Resizable + sortable table columns in the admin dashboard (Table view)
- Unique newsletter addresses per feed (for example `[email protected]`) - Unique newsletter addresses per feed (for example `[email protected]`)
- ForwardEmail webhook ingestion with source-IP verification - Cloudflare Email Workers ingestion (no third-party service)
- ForwardEmail webhook ingestion with source-IP verification (optional alternative)
- Optional per-feed sender allowlist (`[email protected]` or `domain.com`) - Optional per-feed sender allowlist (`[email protected]` or `domain.com`)
- RSS generation on demand (`/rss/:feedId`) - RSS generation on demand (`/rss/:feedId`)
- Atom feed at `/atom/:feedId`
- Email attachments stored in Cloudflare R2 and exposed as RSS enclosures (optional)
- Cloudflare KV storage for feed config + email metadata/content - Cloudflare KV storage for feed config + email metadata/content
- Password-protected admin UI - Password-protected admin UI
- Fully self-hosted on your Cloudflare account
## Architecture ## Architecture
1. ForwardEmail forwards inbound messages to `https://yourdomain.com/api/inbound`. Two ingestion methods are supported — pick one or use both:
2. The Worker validates the request source against ForwardEmail MX IP ranges.
3. The Worker parses and stores incoming content in KV. | Method | How it works |
4. `https://yourdomain.com/rss/:feedId` renders RSS from stored items. | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
5. `/admin` provides feed management and email deletion. | **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 `[email protected]`.
2. The Worker resolves the feed from the recipient address and stores the email in KV.
3. `https://yourdomain.com/rss/:feedId` renders RSS from stored items.
4. `/admin` provides feed management and email deletion.
Main routes: Main routes:
- `src/routes/inbound.ts`: webhook ingestion - `src/lib/cloudflare-email.ts`: Cloudflare Email Workers ingestion
- `src/routes/inbound.ts`: ForwardEmail webhook ingestion
- `src/routes/rss.ts`: RSS rendering - `src/routes/rss.ts`: RSS rendering
- `src/routes/atom.ts`: Atom feed rendering
- `src/routes/files.ts`: attachment file serving from R2
- `src/routes/admin.ts`: admin UI + feed CRUD - `src/routes/admin.ts`: admin UI + feed CRUD
## Requirements ## Requirements
- Node.js 20+ - Node.js 20+
- A Cloudflare account - A Cloudflare account (free plan works — Workers, KV, and Email Routing are all included)
- A domain managed in Cloudflare DNS - A domain added to Cloudflare as a zone (DNS managed by Cloudflare)
- A ForwardEmail account - A ForwardEmail account _(Option B only)_
## Cloudflare setup
If your domain is not yet on Cloudflare: in the [Cloudflare dashboard](https://dash.cloudflare.com/), go to _Add a site_, enter your domain, choose the Free plan, and follow the instructions to update your nameservers at your registrar. Wait for the zone to become active (usually a few minutes).
## Setup ## Setup
@@ -53,20 +70,36 @@ Main routes:
npx wrangler login npx wrangler login
``` ```
3. Run setup: 3. Run setup:
```bash ```bash
bash setup.sh bash setup.sh
``` ```
`setup.sh` will: The script will prompt for an admin password and your domain, then:
- install npm dependencies
- verify Cloudflare auth (`wrangler whoami`)
- create KV namespaces (`EMAIL_STORAGE` + preview) in your account
- set the `ADMIN_PASSWORD` secret in the `production` environment
- generate `wrangler.toml` from `wrangler-example.toml` with your KV IDs, domain, and today's compatibility date
- install npm dependencies 4. Configure email ingestion — choose **one** of the two options below.
- verify Cloudflare auth (`wrangler whoami`)
- create KV namespaces (`EMAIL_STORAGE` + preview)
- set the `ADMIN_PASSWORD` secret in `production`
- generate `wrangler.toml` from `wrangler-example.toml`
- stamp `compatibility_date` to the current date
4. Configure ForwardEmail DNS records in Cloudflare: ### Option A — Cloudflare Email Workers (recommended)
No third-party service required. Cloudflare receives the email and hands it directly to the Worker.
1. In the Cloudflare dashboard, go to _Email → Email Routing_ for your zone and click **Enable Email Routing**. Cloudflare will prompt you to add MX and SPF records — accept and it adds them automatically.
2. Under _Email Routing → Routing Rules_, add a **Catch-all** rule:
- Action: **Send to Worker**
- Worker: `kill-the-news` (the name from `wrangler.toml`)
That's it. No webhook configuration is needed.
### Option B — ForwardEmail (alternative)
Use this if you prefer ForwardEmail's additional features (sender filtering, open-tracking, etc.).
Add these DNS records in Cloudflare (_DNS → Records_):
| Type | Name | Content | Notes | | Type | Name | Content | Notes |
| ---- | ---- | ---------------------------------------------------- | ----------------------- | | ---- | ---- | ---------------------------------------------------- | ----------------------- |
@@ -75,14 +108,22 @@ Main routes:
| TXT | @ | `"forward-email=https://yourdomain.com/api/inbound"` | webhook target | | TXT | @ | `"forward-email=https://yourdomain.com/api/inbound"` | webhook target |
| TXT | @ | `"v=spf1 include:spf.forwardemail.net -all"` | SPF | | TXT | @ | `"v=spf1 include:spf.forwardemail.net -all"` | SPF |
Replace `yourdomain.com` with your actual domain.
The Worker verifies each webhook request against ForwardEmail's published MX IP list before processing it.
5. Deploy: 5. Deploy:
```bash ```bash
npm run deploy npm run deploy
``` ```
Wrangler will create the Worker and register `yourdomain.com` (and `www.yourdomain.com`) as custom domains pointing to it. Cloudflare handles TLS automatically.
6. Open `https://yourdomain.com/admin` and sign in. 6. Open `https://yourdomain.com/admin` and sign in.
> **Tip:** To verify the Worker is running, check _Workers & Pages → kill-the-news_ in the Cloudflare dashboard. The _Custom Domains_ tab should list your domain once the deploy succeeds.
## Development ## Development
```bash ```bash
@@ -98,13 +139,76 @@ npm run build
- Keep `compatibility_date` fresh when doing runtime upgrades. - Keep `compatibility_date` fresh when doing runtime upgrades.
- `ADMIN_PASSWORD` is a Cloudflare Worker secret, not a plain env var in config. - `ADMIN_PASSWORD` is a Cloudflare Worker secret, not a plain env var in config.
### Feed size limit
By default the worker keeps emails until the feed's stored data exceeds **512 KB**, then drops the oldest entries (and their KV records) to stay under the limit. This is more robust than a fixed entry count for HTML-heavy newsletters.
To override the threshold, add to `wrangler.toml` under `[vars]`:
```toml
FEED_MAX_SIZE_BYTES = "524288" # 512 KB — adjust as needed
```
### Email attachments (R2)
When an incoming email contains attachments, the Worker can store them in a Cloudflare R2 bucket and expose them as `<enclosure>` elements in the RSS feed (and `<link rel="enclosure">` in Atom). Each attachment is served at `/files/{id}/{filename}` with an immutable cache header.
This feature is **optional**. If no R2 bucket is bound, attachments are silently ignored and nothing else changes.
**Setup:**
1. Create an R2 bucket in the Cloudflare dashboard (_R2 Object Storage → Create bucket_), or with Wrangler:
```bash
npx wrangler r2 bucket create your-bucket-name
```
2. In `wrangler.toml`, uncomment and fill in the R2 binding (the commented block from `wrangler-example.toml`):
```toml
r2_buckets = [
{ binding = "ATTACHMENT_BUCKET", bucket_name = "your-bucket-name", preview_bucket_name = "your-bucket-name-preview" }
]
```
Do the same under `[env.production]` (without `preview_bucket_name`).
3. Redeploy:
```bash
npm run deploy
```
Attachments are deleted from R2 automatically when the corresponding email is deleted from the admin UI, or when an email is dropped during feed size trimming.
### External auth provider (Authelia / Authentik / reverse proxy)
Instead of the built-in password login you can delegate admin authentication to a reverse proxy that sets a trusted user header (`Remote-User` or `X-Forwarded-User`).
**Required Worker secrets** (set with `wrangler secret put`, never in `[vars]`):
| Secret | Description |
| ------------------- | ---------------------------------------------- |
| `PROXY_AUTH_SECRET` | Shared secret between the proxy and the Worker |
**Required `[vars]`** in `wrangler.toml`:
```toml
PROXY_TRUSTED_IPS = "10.0.0.1" # comma-separated IPs of your reverse proxy
```
When both are configured, the Worker authenticates a request if:
1. `CF-Connecting-IP` is in `PROXY_TRUSTED_IPS`
2. The `X-Auth-Proxy-Secret` header matches `PROXY_AUTH_SECRET`
3. `Remote-User` or `X-Forwarded-User` is non-empty
Password login remains available as a fallback when the proxy check fails.
> **Security note:** `CF-Connecting-IP` can be spoofed on direct `workers.dev` requests. Disable the `workers.dev` subdomain in production (`workers_dev = false` in `[env.production]`).
## Security notes ## Security notes
- Inbound webhook access is IP-restricted to ForwardEmail MX sources. - 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 auth uses a signed, `HttpOnly`, `Secure`, `SameSite=Strict` cookie.
- Admin responses are `no-store` to avoid cache leakage. - Admin responses are `no-store` to avoid cache leakage.
- For high-value feeds, set `Allowed senders` so only known sender addresses/domains are accepted. - 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. - 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.
## Upgrading dependencies ## Upgrading dependencies
@@ -119,6 +223,11 @@ npm run build
Then update `compatibility_date` and redeploy. Then update `compatibility_date` and redeploy.
## 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 ## License
MIT MIT
+29
View File
@@ -0,0 +1,29 @@
# TODO
Feature gaps identified by comparing with [kill-the-newsletter](https://github.com/leafac/kill-the-newsletter).
## Quick wins
- [x] **Author field in RSS entries** — expose the `from` address as `<author>` in each RSS `<item>`. The value is already stored in KV, just not rendered in the feed XML.
- [x] **HTML view for individual entries** — serve each email as an HTML page at e.g. `/entries/:feedId/:timestamp`. Useful for reading emails outside a feed reader and for debugging. kill-the-newsletter serves these at `/feeds/{feedId}/entries/{entryId}.html` with a Content-Security-Policy header.
- [x] **JSON API for feed creation** — accept `Content-Type: application/json` on `POST /admin/feeds` and return `{ feedId, email, feedUrl }`. Useful for automation (e.g. Tofu/OpenTofu provisioning).
## Medium effort
- [x] **Size-based feed trimming** — instead of a fixed 50-entry cap, drop the oldest entries when the feed exceeds a size threshold (kill-the-newsletter uses ~512 KB). More robust for HTML-heavy newsletters where one entry can dominate.
- [x] **Atom feed format** — expose feeds as Atom (`application/atom+xml`) in addition to or instead of RSS 2.0. Atom has better native support for HTML content and author metadata.
- [x] **Authelia / external auth provider support** — allow delegating admin authentication to an external identity provider (e.g. Authelia, Authentik) via a trusted header (`Remote-User`, `X-Forwarded-User`) set by a reverse proxy. The Worker would accept the header as proof of authentication instead of checking the cookie, with a configurable secret or IP allowlist to trust only the proxy.
## Heavy
- [x] **Email attachments as RSS enclosures** — store attachments in Cloudflare R2 and expose them as `<enclosure>` elements in the feed. kill-the-newsletter serves them at `/files/{enclosureId}/{filename}`.
- [x] **WebSub (PubSubHubbub) push notifications** — notify subscribers in real time when a new email arrives, instead of requiring them to poll the feed. Requires either integrating a public WebSub hub or implementing the hub protocol directly.
- [x] **Rate limiting via Cloudflare WAF rules** — protect `/api/inbound` and `/admin` against abuse. Configure WAF custom rules in the Cloudflare dashboard (or via Terraform): rate-limit `/api/inbound` to ~60 req/min per IP, and `/admin` to ~20 req/min per IP. No code changes required; this is pure infrastructure configuration.
- [ ] **Migrate feed metadata to Durable Objects for atomic writes** — the current KV-based metadata store has a read-modify-write race condition: two concurrent emails to the same feed can silently overwrite each other's changes. Cloudflare Durable Objects serialise access per feed and eliminate the race entirely. Requires replacing `feed:<feedId>:metadata` KV writes in `src/lib/email-processor.ts` with a Durable Object that exposes an `appendEmail()` RPC, updating `wrangler.toml` with a DO binding, and migrating existing metadata at deploy time.
+1
View File
@@ -0,0 +1 @@
kill-the.news
+1068
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
import prettier from "eslint-config-prettier";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist/", "coverage/"] },
...tseslint.configs.recommended,
prettier,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
{
files: ["src/**/*.test.ts", "src/test/**/*.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
);
+3037 -825
View File
File diff suppressed because it is too large. Load diff
+42 -15
View File
@@ -1,34 +1,61 @@
{ {
"name": "email-to-rss", "name": "kill-the-news",
"version": "0.1.0", "version": "0.1.0",
"description": "A service that converts email newsletters to RSS feeds using Cloudflare Workers", "description": "Convert email newsletters into private RSS feeds using Cloudflare Workers",
"main": "dist/worker.js", "main": "dist/worker.js",
"scripts": { "scripts": {
"build": "wrangler deploy --dry-run --outdir=dist", "build:client": "node scripts/build-client.mjs",
"build": "wrangler deploy --config wrangler.build.toml --dry-run --outdir=dist --no-experimental-autoconfig",
"prebuild": "npm run build:client",
"format": "prettier --write '**/*.{js,ts,css,json,md}'", "format": "prettier --write '**/*.{js,ts,css,json,md}'",
"format:check": "prettier --check '**/*.{js,ts,css,json,md}'",
"lint": "eslint src",
"dev": "wrangler dev", "dev": "wrangler dev",
"predev": "npm run build:client",
"deploy": "wrangler deploy --env production", "deploy": "wrangler deploy --env production",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"prepare": "husky && npm run build:client"
},
"lint-staged": {
"*.{ts,js}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css}": [
"prettier --write"
]
}, },
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260206.0", "@cloudflare/workers-types": "4.20260522.1",
"@types/escape-html": "^1.0.4",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/rss": "^0.0.32", "@types/rss": "^0.0.32",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "4.1.7",
"happy-dom": "^20.5.0", "esbuild": "^0.28.0",
"msw": "^2.12.8", "eslint": "^10.0.0",
"prettier": "^3.8.1", "eslint-config-prettier": "^10.1.8",
"typescript": "^5.9.3", "happy-dom": "20.9.0",
"vitest": "^4.0.18", "husky": "^9.1.7",
"wrangler": "^4.63.0" "lint-staged": "^17.0.5",
"msw": "2.14.6",
"prettier": "3.8.3",
"typescript": "6.0.3",
"typescript-eslint": "^8.59.4",
"vitest": "4.1.7",
"wrangler": "4.94.0"
}, },
"dependencies": { "dependencies": {
"feed": "^5.2.0", "@hono/zod-validator": "^0.8.0",
"hono": "^4.11.7", "escape-html": "^1.0.3",
"zod": "^4.3.6" "feed": "5.2.1",
"hono": "4.12.22",
"linkedom": "^0.18.12",
"postal-mime": "^2.7.4",
"zod": "4.4.3"
} }
} }
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
// Compiles client-side TypeScript files with esbuild and writes the result
// as TypeScript string-constant modules importable by the Cloudflare Worker.
import { build } from 'esbuild';
import { writeFileSync, mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const entries = [
{
input: 'src/scripts/client/dashboard.ts',
exportName: 'dashboardScript',
output: 'src/scripts/generated/dashboard.ts',
},
{
input: 'src/scripts/client/emails-page.ts',
exportName: 'emailsPageScript',
output: 'src/scripts/generated/emails-page.ts',
},
];
mkdirSync(join(root, 'src/scripts/generated'), { recursive: true });
for (const { input, exportName, output } of entries) {
const result = await build({
entryPoints: [join(root, input)],
bundle: true,
minify: true,
write: false,
target: 'es2020',
platform: 'browser',
format: 'iife',
});
const code = new TextDecoder().decode(result.outputFiles[0].contents);
const content = [
'// AUTO-GENERATED by scripts/build-client.mjs — do not edit directly.',
'// Source: ' + input,
'// Run `npm run build:client` to regenerate.',
'/* eslint-disable */',
'export const ' + exportName + ' = ' + JSON.stringify(code) + ';',
'',
].join('\n');
writeFileSync(join(root, output), content);
console.log('Built:', output);
}
+2 -2
View File
@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
echo "🚀 Setting up Email to RSS service..." echo "🚀 Setting up kill-the-news..."
if ! command -v npm >/dev/null 2>&1 || ! command -v npx >/dev/null 2>&1 || ! command -v node >/dev/null 2>&1; then if ! command -v npm >/dev/null 2>&1 || ! command -v npx >/dev/null 2>&1 || ! command -v node >/dev/null 2>&1; then
echo "❌ Error: Node.js (with npm and npx) is required but not found." echo "❌ Error: Node.js (with npm and npx) is required but not found."
@@ -17,7 +17,7 @@ fi
WORKER_NAME="$(grep -E '^name = "' wrangler-example.toml | head -1 | cut -d'"' -f2)" WORKER_NAME="$(grep -E '^name = "' wrangler-example.toml | head -1 | cut -d'"' -f2)"
if [ -z "$WORKER_NAME" ]; then if [ -z "$WORKER_NAME" ]; then
WORKER_NAME="email-to-rss" WORKER_NAME="kill-the-news"
fi fi
echo "📦 Installing dependencies..." echo "📦 Installing dependencies..."
+23
View File
@@ -0,0 +1,23 @@
/** Maximum total size of emails stored per feed (bytes). */
export const FEED_MAX_BYTES = 524288; // 512 KB
/** Cache TTL for ForwardEmail.net IP list (milliseconds). */
export const FORWARD_EMAIL_IPS_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
/** Admin session cookie max-age (seconds). */
export const ADMIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 1 week
/** Maximum number of feed items exposed in RSS/Atom responses. */
export const MAX_FEED_ITEMS = 20;
/** Maximum number of email entries kept in feed metadata. */
export const MAX_METADATA_EMAILS = 50;
/** Default WebSub lease duration (seconds). */
export const DEFAULT_LEASE_SECONDS = 86400; // 24 hours
/** Maximum WebSub lease duration (seconds). */
export const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days
/** KV key for the global feed list. */
export const FEEDS_LIST_KEY = "feeds:list";
+576 -76
View File
@@ -2,79 +2,579 @@
* Collection of common nouns for feed ID generation * Collection of common nouns for feed ID generation
*/ */
export const nouns = [ export const nouns = [
'actor', 'almond', 'amber', 'anchor', 'angel', 'animal', 'answer', 'apple', "actor",
'autumn', 'avenue', 'badge', 'bagel', 'baker', 'ballet', 'bamboo', "almond",
'banana', 'basket', 'beach', 'beard', 'beauty', 'beetle', 'berry', 'bicycle', "amber",
'bird', 'blanket', 'blossom', 'boat', 'bottle', 'bowl', 'breeze', 'bubble', "anchor",
'bucket', 'button', 'cabin', 'cactus', 'cafe', 'camera', 'candle', 'candy', "angel",
'canvas', 'canyon', 'captain', 'carpet', 'carrot', 'castle', 'cave', 'cellar', "animal",
'chair', 'chalk', 'cheese', 'cherry', 'chest', 'chicken', 'chimney', "answer",
'circus', 'cliff', 'clock', 'cloud', 'clover', 'coast', 'cobalt', 'cocoa', "apple",
'coffee', 'coin', 'comet', 'compass', 'cookie', 'copper', 'coral', 'corner', "autumn",
'cotton', 'cradle', 'craft', 'creek', 'cricket', 'crown', 'crystal', 'cube', "avenue",
'cupboard', 'curtain', 'cushion', 'daisy', 'dance', 'date', 'dawn', 'deer', "badge",
'desert', 'dew', 'diamond', 'dinner', 'dish', 'doctor', 'dolphin', "bagel",
'donut', 'door', 'dream', 'dress', 'drink', 'drum', 'duck', 'dusk', "baker",
'eagle', 'earth', 'echo', 'emerald', 'engine', 'evening', 'face', 'fairy', "ballet",
'fall', 'family', 'fan', 'farm', 'feather', 'fence', 'ferry', 'field', "bamboo",
'finger', 'fire', 'fish', 'flag', 'flame', 'flash', 'flavor', 'flight', "banana",
'floor', 'flour', 'flower', 'flute', 'fog', 'foil', 'forest', 'fork', "basket",
'fox', 'frame', 'friend', 'frog', 'frost', 'fruit', 'garden', 'garlic', "beach",
'gate', 'gem', 'gift', 'ginger', 'giraffe', 'glacier', 'glass', "beard",
'glitter', 'glove', 'glow', 'goat', 'gold', 'grape', 'grass', 'gravel', "beauty",
'gravity', 'guitar', 'gum', 'hair', 'hammer', 'hand', 'harbor', 'harp', "beetle",
'hat', 'hawk', 'heart', 'heath', 'heaven', 'helmet', 'herb', 'hill', "berry",
'hippo', 'honey', 'hood', 'horn', 'horse', 'hotel', 'hour', 'house', "bicycle",
'hunter', 'ice', 'icicle', 'idea', 'ink', 'insect', 'iron', 'island', "bird",
'ivy', 'jacket', 'jade', 'jam', 'jasmine', 'jelly', 'jewel', "blanket",
'joke', 'journal', 'journey', 'joy', 'judge', 'jungle', 'kettle', 'key', "blossom",
'kid', 'kingdom', 'kitchen', 'kite', 'kitten', 'knight', "boat",
'lab', 'ladder', 'lake', 'lamb', 'lamp', 'land', 'lantern', "bottle",
'laptop', 'laugh', 'lava', 'lawn', 'leaf', 'legend', 'lemon', 'letter', "bowl",
'library', 'light', 'lily', 'lime', 'lion', 'lip', 'lobby', 'lock', "breeze",
'locket', 'lodge', 'lotus', 'love', 'lunch', 'lyric', 'magic', 'magnet', "bubble",
'mango', 'maple', 'marble', 'market', 'mask', 'meadow', 'melody', 'melon', "bucket",
'memory', 'metal', 'meteor', 'milk', 'mint', 'mirror', 'mist', 'mitten', "button",
'moon', 'morning', 'moth', 'motor', 'mountain', 'mouse', "cabin",
'movie', 'muffin', 'museum', 'music', 'myth', 'napkin', 'nectar', 'needle', "cactus",
'nest', 'net', 'nickel', 'night', 'nose', 'note', 'novel', 'number', "cafe",
'nurse', 'nutmeg', 'oasis', 'ocean', 'olive', 'onion', 'opera', 'orange', "camera",
'orbit', 'orchard', 'orchid', 'ostrich', 'otter', 'oven', 'owl', 'oxygen', "candle",
'oyster', 'page', 'paint', 'palace', 'palm', 'pan', 'pancake', 'panda', "candy",
'paper', 'parade', 'parcel', 'park', 'parrot', 'party', 'pasta', 'patch', "canvas",
'path', 'peach', 'peanut', 'pear', 'pearl', 'pebble', 'pencil', 'penny', "canyon",
'people', 'pepper', 'petal', 'phone', 'photo', 'piano', 'pickle', 'picture', "captain",
'pie', 'pillow', 'pine', 'pink', 'pirate', 'pizza', 'planet', "carpet",
'plant', 'plum', 'pocket', 'poem', 'poet', 'point', 'pony', 'pool', "carrot",
'popcorn', 'porch', 'port', 'potato', 'powder', 'prairie', 'pretzel', 'prism', "castle",
'prose', 'puppet', 'puppy', 'puzzle', 'quail', 'quartz', 'queen', 'quilt', "cave",
'rabbit', 'raccoon', 'radio', 'raft', 'rain', 'rainbow', 'raisin', "cellar",
'ranch', 'rapids', 'raven', 'ray', 'record', 'reef', 'ribbon', 'rice', "chair",
'ring', 'river', 'road', 'robin', 'robot', 'rock', 'rocket', 'rodeo', "chalk",
'roof', 'room', 'root', 'rope', 'rose', 'ruby', 'rug', 'ruler', 'sage', "cheese",
'sail', 'salad', 'salmon', 'salt', 'sand', 'sandal', 'sauce', 'saucer', "cherry",
'scale', 'scarf', 'school', 'sea', 'seed', 'shadow', 'shell', 'ship', "chest",
'shirt', 'shoe', 'shop', 'shower', 'shrimp', 'side', 'sign', 'silk', "chicken",
'silver', 'singer', 'sink', 'sky', 'sled', 'sleet', 'sleigh', 'slice', "chimney",
'slide', 'slipper', 'slope', 'smoke', 'snail', 'snake', 'snow', 'soap', "circus",
'sock', 'soda', 'sofa', 'soil', 'song', 'soup', 'spade', 'spark', 'sparrow', "cliff",
'spice', 'spider', 'spoon', 'spot', 'spring', 'sprout', 'square', 'squirrel', "clock",
'stable', 'stage', 'stair', 'stamp', 'star', 'station', 'steam', 'steel', "cloud",
'stem', 'stick', 'stone', 'stork', 'storm', 'story', 'stove', 'straw', "clover",
'stream', 'street', 'string', 'studio', 'sugar', 'summer', 'sun', 'sunset', "coast",
'swan', 'sweater', 'sweets', 'sword', 'table', 'tablet', 'tail', 'talent', "cobalt",
'tangerine', 'tank', 'tea', 'team', 'teapot', 'tear', 'temple', 'tennis', "cocoa",
'tent', 'theater', 'thistle', 'thought', 'thread', 'thunder', "coffee",
'ticket', 'tide', 'tiger', 'tile', 'time', 'toast', 'toffee', 'tomato', "coin",
'tooth', 'top', 'torch', 'tower', 'town', 'toy', 'track', "comet",
'train', 'tree', 'triangle', 'trick', 'truck', 'trumpet', 'tulip', 'tunnel', "compass",
'turkey', 'turtle', 'twig', 'uncle', 'unicorn', 'universe', 'vacuum', 'valley', "cookie",
'vanilla', 'vase', 'velvet', 'vessel', 'village', 'vine', 'violin', "copper",
'voice', 'volcano', 'voyage', 'wagon', 'walnut', 'waltz', 'water', "coral",
'wave', 'wax', 'weather', 'web', 'wedding', 'whale', 'wheat', "corner",
'wheel', 'whistle', 'whisper', 'willow', 'wind', 'window', 'wine', 'wing', "cotton",
'winter', 'wire', 'wish', 'wizard', 'wood', 'wool', "cradle",
'word', 'world', 'wreath', 'wrist', 'writer', 'xylophone', 'yacht', "craft",
'yard', 'yarn', 'year', 'yolk', 'zebra', 'zephyr', 'zinc', 'zipper', "creek",
'zone', 'zoo' "cricket",
]; "crown",
"crystal",
"cube",
"cupboard",
"curtain",
"cushion",
"daisy",
"dance",
"date",
"dawn",
"deer",
"desert",
"dew",
"diamond",
"dinner",
"dish",
"doctor",
"dolphin",
"donut",
"door",
"dream",
"dress",
"drink",
"drum",
"duck",
"dusk",
"eagle",
"earth",
"echo",
"emerald",
"engine",
"evening",
"face",
"fairy",
"fall",
"family",
"fan",
"farm",
"feather",
"fence",
"ferry",
"field",
"finger",
"fire",
"fish",
"flag",
"flame",
"flash",
"flavor",
"flight",
"floor",
"flour",
"flower",
"flute",
"fog",
"foil",
"forest",
"fork",
"fox",
"frame",
"friend",
"frog",
"frost",
"fruit",
"garden",
"garlic",
"gate",
"gem",
"gift",
"ginger",
"giraffe",
"glacier",
"glass",
"glitter",
"glove",
"glow",
"goat",
"gold",
"grape",
"grass",
"gravel",
"gravity",
"guitar",
"gum",
"hair",
"hammer",
"hand",
"harbor",
"harp",
"hat",
"hawk",
"heart",
"heath",
"heaven",
"helmet",
"herb",
"hill",
"hippo",
"honey",
"hood",
"horn",
"horse",
"hotel",
"hour",
"house",
"hunter",
"ice",
"icicle",
"idea",
"ink",
"insect",
"iron",
"island",
"ivy",
"jacket",
"jade",
"jam",
"jasmine",
"jelly",
"jewel",
"joke",
"journal",
"journey",
"joy",
"judge",
"jungle",
"kettle",
"key",
"kid",
"kingdom",
"kitchen",
"kite",
"kitten",
"knight",
"lab",
"ladder",
"lake",
"lamb",
"lamp",
"land",
"lantern",
"laptop",
"laugh",
"lava",
"lawn",
"leaf",
"legend",
"lemon",
"letter",
"library",
"light",
"lily",
"lime",
"lion",
"lip",
"lobby",
"lock",
"locket",
"lodge",
"lotus",
"love",
"lunch",
"lyric",
"magic",
"magnet",
"mango",
"maple",
"marble",
"market",
"mask",
"meadow",
"melody",
"melon",
"memory",
"metal",
"meteor",
"milk",
"mint",
"mirror",
"mist",
"mitten",
"moon",
"morning",
"moth",
"motor",
"mountain",
"mouse",
"movie",
"muffin",
"museum",
"music",
"myth",
"napkin",
"nectar",
"needle",
"nest",
"net",
"nickel",
"night",
"nose",
"note",
"novel",
"number",
"nurse",
"nutmeg",
"oasis",
"ocean",
"olive",
"onion",
"opera",
"orange",
"orbit",
"orchard",
"orchid",
"ostrich",
"otter",
"oven",
"owl",
"oxygen",
"oyster",
"page",
"paint",
"palace",
"palm",
"pan",
"pancake",
"panda",
"paper",
"parade",
"parcel",
"park",
"parrot",
"party",
"pasta",
"patch",
"path",
"peach",
"peanut",
"pear",
"pearl",
"pebble",
"pencil",
"penny",
"people",
"pepper",
"petal",
"phone",
"photo",
"piano",
"pickle",
"picture",
"pie",
"pillow",
"pine",
"pink",
"pirate",
"pizza",
"planet",
"plant",
"plum",
"pocket",
"poem",
"poet",
"point",
"pony",
"pool",
"popcorn",
"porch",
"port",
"potato",
"powder",
"prairie",
"pretzel",
"prism",
"prose",
"puppet",
"puppy",
"puzzle",
"quail",
"quartz",
"queen",
"quilt",
"rabbit",
"raccoon",
"radio",
"raft",
"rain",
"rainbow",
"raisin",
"ranch",
"rapids",
"raven",
"ray",
"record",
"reef",
"ribbon",
"rice",
"ring",
"river",
"road",
"robin",
"robot",
"rock",
"rocket",
"rodeo",
"roof",
"room",
"root",
"rope",
"rose",
"ruby",
"rug",
"ruler",
"sage",
"sail",
"salad",
"salmon",
"salt",
"sand",
"sandal",
"sauce",
"saucer",
"scale",
"scarf",
"school",
"sea",
"seed",
"shadow",
"shell",
"ship",
"shirt",
"shoe",
"shop",
"shower",
"shrimp",
"side",
"sign",
"silk",
"silver",
"singer",
"sink",
"sky",
"sled",
"sleet",
"sleigh",
"slice",
"slide",
"slipper",
"slope",
"smoke",
"snail",
"snake",
"snow",
"soap",
"sock",
"soda",
"sofa",
"soil",
"song",
"soup",
"spade",
"spark",
"sparrow",
"spice",
"spider",
"spoon",
"spot",
"spring",
"sprout",
"square",
"squirrel",
"stable",
"stage",
"stair",
"stamp",
"star",
"station",
"steam",
"steel",
"stem",
"stick",
"stone",
"stork",
"storm",
"story",
"stove",
"straw",
"stream",
"street",
"string",
"studio",
"sugar",
"summer",
"sun",
"sunset",
"swan",
"sweater",
"sweets",
"sword",
"table",
"tablet",
"tail",
"talent",
"tangerine",
"tank",
"tea",
"team",
"teapot",
"tear",
"temple",
"tennis",
"tent",
"theater",
"thistle",
"thought",
"thread",
"thunder",
"ticket",
"tide",
"tiger",
"tile",
"time",
"toast",
"toffee",
"tomato",
"tooth",
"top",
"torch",
"tower",
"town",
"toy",
"track",
"train",
"tree",
"triangle",
"trick",
"truck",
"trumpet",
"tulip",
"tunnel",
"turkey",
"turtle",
"twig",
"uncle",
"unicorn",
"universe",
"vacuum",
"valley",
"vanilla",
"vase",
"velvet",
"vessel",
"village",
"vine",
"violin",
"voice",
"volcano",
"voyage",
"wagon",
"walnut",
"waltz",
"water",
"wave",
"wax",
"weather",
"web",
"wedding",
"whale",
"wheat",
"wheel",
"whistle",
"whisper",
"willow",
"wind",
"window",
"wine",
"wing",
"winter",
"wire",
"wish",
"wizard",
"wood",
"wool",
"word",
"world",
"wreath",
"wrist",
"writer",
"xylophone",
"yacht",
"yard",
"yarn",
"year",
"yolk",
"zebra",
"zephyr",
"zinc",
"zipper",
"zone",
"zoo",
];
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import worker from "./index";
import { createMockEnv } from "./test/setup";
import type { Env } from "./types";
const env = createMockEnv();
function req(path: string, init: RequestInit = {}): Request {
return new Request(`https://test.getmynews.app${path}`, init);
}
describe("CORS middleware", () => {
it("adds CORS headers for an allowed origin", async () => {
const res = await worker.fetch(
req("/rss/some-feed", { headers: { Origin: "https://getmynews.app" } }),
env as unknown as Env,
);
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
"https://getmynews.app",
);
});
it("omits CORS headers for an unknown origin", async () => {
const res = await worker.fetch(
req("/rss/some-feed", { headers: { Origin: "https://evil.com" } }),
env as unknown as Env,
);
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
});
it("handles OPTIONS preflight for an allowed origin with 204", async () => {
const res = await worker.fetch(
req("/rss/some-feed", {
method: "OPTIONS",
headers: {
Origin: "https://getmynews.app",
"Access-Control-Request-Method": "GET",
},
}),
env as unknown as Env,
);
expect(res.status).toBe(204);
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
"https://getmynews.app",
);
});
});
+115 -70
View File
@@ -1,21 +1,30 @@
import { Hono } from 'hono'; import { Hono } from "hono";
import { handle as handleInbound } from './routes/inbound'; import { cors } from "hono/cors";
import { handle as handleRSS } from './routes/rss'; import { handle as handleInbound } from "./routes/inbound";
import { handle as handleAdmin } from './routes/admin'; import { handle as handleRSS } from "./routes/rss";
import { Env } from './types'; import { handle as handleAtom } from "./routes/atom";
import { handle as handleAdmin } from "./routes/admin";
import { handle as handleEntry } from "./routes/entries";
import { handle as handleFiles } from "./routes/files";
import { hubRouter } from "./routes/hub";
import { handleCloudflareEmail } from "./lib/cloudflare-email";
import { Env } from "./types";
import { logger } from "./lib/logger";
import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants";
// Define allowed origins for CORS type AppEnv = { Bindings: Env };
const ALLOWED_ORIGINS = ['https://getmynews.app', 'https://www.getmynews.app'];
const ALLOWED_ORIGINS = ["https://getmynews.app", "https://www.getmynews.app"];
// Fallback ForwardEmail.net IP addresses in case API fetch fails // Fallback ForwardEmail.net IP addresses in case API fetch fails
const FALLBACK_FORWARD_EMAIL_IPS = [ const FALLBACK_FORWARD_EMAIL_IPS = [
'138.197.213.185', // mx1.forwardemail.net "138.197.213.185", // mx1.forwardemail.net
'121.127.44.56', // mx1.forwardemail.net (alternate) "121.127.44.56", // mx1.forwardemail.net (alternate)
'104.248.224.170' // mx2.forwardemail.net "104.248.224.170", // mx2.forwardemail.net
]; ];
// Create the main Hono app // Create the main Hono app
const app = new Hono(); const app = new Hono<AppEnv>();
// Cache for ForwardEmail.net IPs with expiration // Cache for ForwardEmail.net IPs with expiration
let forwardEmailIpsCache: { let forwardEmailIpsCache: {
@@ -30,117 +39,153 @@ async function getForwardEmailIps(): Promise<string[]> {
if (forwardEmailIpsCache && forwardEmailIpsCache.expiresAt > Date.now()) { if (forwardEmailIpsCache && forwardEmailIpsCache.expiresAt > Date.now()) {
return forwardEmailIpsCache.ips; return forwardEmailIpsCache.ips;
} }
// Fetch the latest IPs from ForwardEmail.net // Fetch the latest IPs from ForwardEmail.net
const response = await fetch('https://forwardemail.net/ips/v4.json', { const response = await fetch("https://forwardemail.net/ips/v4.json", {
headers: { headers: {
'User-Agent': 'Email-to-RSS/1.0', "User-Agent": "Email-to-RSS/1.0",
}, },
cf: { cf: {
cacheTtl: 3600, // Cache for 1 hour in Cloudflare's cache cacheTtl: 3600, // Cache for 1 hour in Cloudflare's cache
cacheEverything: true, cacheEverything: true,
}, },
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch IPs: ${response.status}`); throw new Error(`Failed to fetch IPs: ${response.status}`);
} }
// Define the expected type for the API response // Define the expected type for the API response
interface IpEntry { interface IpEntry {
hostname: string; hostname: string;
ipv4: string[]; ipv4: string[];
updated: string; updated: string;
} }
const data = await response.json() as IpEntry[]; const data = (await response.json()) as IpEntry[];
// Extract IPs for mx1 and mx2 servers // Extract IPs for mx1 and mx2 servers
const mxIps = data const mxIps = data
.filter(entry => .filter(
entry.hostname === 'mx1.forwardemail.net' || (entry) =>
entry.hostname === 'mx2.forwardemail.net' entry.hostname === "mx1.forwardemail.net" ||
entry.hostname === "mx2.forwardemail.net",
) )
.flatMap(entry => entry.ipv4); .flatMap((entry) => entry.ipv4);
// Store in cache for 24 hours
forwardEmailIpsCache = { forwardEmailIpsCache = {
ips: mxIps, ips: mxIps,
expiresAt: Date.now() + (24 * 60 * 60 * 1000) // 24 hours expiresAt: Date.now() + FORWARD_EMAIL_IPS_CACHE_TTL_MS,
}; };
console.log('Fetched ForwardEmail.net IPs:', mxIps); logger.info("Fetched ForwardEmail.net IPs", { count: mxIps.length });
return mxIps; return mxIps;
} catch (error) { } catch (error) {
console.error('Error fetching ForwardEmail.net IPs:', error); logger.error("Failed to fetch ForwardEmail.net IPs", {
error: String(error),
});
// Return fallback IPs if fetch fails // Return fallback IPs if fetch fails
return FALLBACK_FORWARD_EMAIL_IPS; return FALLBACK_FORWARD_EMAIL_IPS;
} }
} }
// CORS middleware app.use(
app.use('*', async (c, next) => { "*",
const origin = c.req.header('Origin'); cors({
if (origin && ALLOWED_ORIGINS.includes(origin)) { origin: ALLOWED_ORIGINS,
c.header('Access-Control-Allow-Origin', origin); allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
c.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); allowHeaders: ["Content-Type", "Authorization"],
c.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); maxAge: 86400,
c.header('Access-Control-Max-Age', '86400'); }),
} );
// Handle preflight requests
if (c.req.method === 'OPTIONS') {
return c.text('', 204);
}
await next();
});
// Group routes by functionality // Group routes by functionality
const api = new Hono(); const api = new Hono<AppEnv>();
const rss = new Hono(); const rss = new Hono<AppEnv>();
const admin = new Hono(); const atom = new Hono<AppEnv>();
const entries = new Hono<AppEnv>();
const files = new Hono<AppEnv>();
const admin = new Hono<AppEnv>();
// Webhook security middleware for /inbound - verify ForwardEmail.net IP // Webhook security middleware for /inbound - verify ForwardEmail.net IP
api.use('/inbound', async (c, next) => { api.use("/inbound", async (c, next) => {
// Get the client IP // Get the client IP
const clientIP = c.req.header('CF-Connecting-IP') || // Cloudflare-specific header const clientIP =
c.req.header('X-Forwarded-For')?.split(',')[0].trim() || c.req.header("CF-Connecting-IP") || // Cloudflare-specific header
c.req.raw.headers.get('x-real-ip') || c.req.header("X-Forwarded-For")?.split(",")[0].trim() ||
'0.0.0.0'; c.req.raw.headers.get("x-real-ip") ||
"0.0.0.0";
// Get the latest ForwardEmail.net IPs // Get the latest ForwardEmail.net IPs
const allowedIps = await getForwardEmailIps(); const allowedIps = await getForwardEmailIps();
// Check if the request is coming from ForwardEmail.net // Check if the request is coming from ForwardEmail.net
if (!allowedIps.includes(clientIP)) { if (!allowedIps.includes(clientIP)) {
console.error(`Unauthorized webhook request from IP: ${clientIP}`); logger.warn("Unauthorized webhook request", { clientIP });
return c.text('Unauthorized', 401); return c.text("Unauthorized", 401);
} }
console.log(`Authorized webhook request from ForwardEmail.net (${clientIP})`); logger.info("Authorized webhook request", { clientIP });
await next(); await next();
}); });
// API routes (inbound webhook) // API routes (inbound webhook)
api.post('/inbound', handleInbound); api.post("/inbound", handleInbound);
// RSS feed routes (public) // RSS feed routes (public)
rss.get('/:feedId', handleRSS); rss.get("/:feedId", handleRSS);
// Atom feed routes (public)
atom.get("/:feedId", handleAtom);
// Email entry HTML view (public)
entries.get("/:feedId/:entryId", handleEntry);
// Attachment file serving (public)
files.get("/:attachmentId/:filename", handleFiles);
// Admin routes (protected) // Admin routes (protected)
admin.route('/', handleAdmin); admin.route("/", handleAdmin);
// Mount the route groups // Mount the route groups
app.route('/api', api); app.route("/api", api);
app.route('/rss', rss); app.route("/rss", rss);
app.route('/admin', admin); app.route("/atom", atom);
app.route("/entries", entries);
app.route("/files", files);
app.route("/admin", admin);
app.route("/hub", hubRouter);
// Health check endpoint for monitoring
app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
// Root path redirects to admin dashboard // Root path redirects to admin dashboard
app.get('/', (c) => c.redirect('/admin')); app.get("/", (c) => c.redirect("/admin"));
// Catch-all for 404s // Catch-all for 404s
app.all('*', (c) => c.text('Not Found', 404)); app.all("*", (c) => c.text("Not Found", 404));
// Export the worker handler // Export both the HTTP fetch handler and the Cloudflare Email handler
export default app; export default {
fetch: app.fetch.bind(app),
async email(
message: ForwardableEmailMessage,
env: Env,
ctx: ExecutionContext,
) {
await handleCloudflareEmail(message, env, ctx);
},
async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext) {
let cursor: string | undefined;
let deleted = 0;
do {
const result = await env.EMAIL_STORAGE.list({ cursor });
await Promise.all(
result.keys.map(({ name }) => env.EMAIL_STORAGE.delete(name)),
);
deleted += result.keys.length;
cursor = result.list_complete ? undefined : result.cursor;
} while (cursor);
logger.info("Demo KV reset complete", { deleted });
},
};
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from "vitest";
import "../test/setup";
import { createMockEnv } from "../test/setup";
import { handleCloudflareEmail } from "./cloudflare-email";
const VALID_FEED_ID = "apple.mountain.42";
const DOMAIN = "test.getmynews.app";
const RAW_EMAIL = [
"From: Sender Name <[email protected]>",
`To: ${VALID_FEED_ID}@${DOMAIN}`,
"Subject: Hello World",
"Date: Thu, 01 Jan 2026 12:00:00 +0000",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
"This is the email body.",
].join("\r\n");
function makeMessage(
overrides: Partial<{ from: string; to: string; rawText: string }> = {},
): ForwardableEmailMessage {
const rawText = overrides.rawText ?? RAW_EMAIL;
const encoder = new TextEncoder();
const bytes = encoder.encode(rawText);
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
return {
from: overrides.from ?? "[email protected]",
to: overrides.to ?? `${VALID_FEED_ID}@${DOMAIN}`,
headers: new Headers(),
raw: stream,
rawSize: bytes.length,
forward: async () => {},
reply: async () => {},
setReject: () => {},
} as unknown as ForwardableEmailMessage;
}
describe("handleCloudflareEmail", () => {
let env: ReturnType<typeof createMockEnv>;
beforeEach(() => {
env = createMockEnv();
});
it("stores email in KV when feed exists", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
await handleCloudflareEmail(
makeMessage(),
env as any,
{ waitUntil: () => {} } as any,
);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails).toHaveLength(1);
expect(metadata.emails[0].subject).toBe("Hello World");
});
it("does not throw when feed does not exist", async () => {
await expect(
handleCloudflareEmail(
makeMessage(),
env as any,
{ waitUntil: () => {} } as any,
),
).resolves.toBeUndefined();
});
it("does not throw when email is malformed", async () => {
const msg = makeMessage({ rawText: "not a valid email" });
await expect(
handleCloudflareEmail(msg, env as any, { waitUntil: () => {} } as any),
).resolves.toBeUndefined();
});
it("uses sender from message.from for allowlist check", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["[email protected]"] }),
);
await handleCloudflareEmail(
makeMessage(),
env as any,
{ waitUntil: () => {} } as any,
);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails).toHaveLength(1);
});
it("rejects email when sender is not in allowlist (stored nothing)", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["[email protected]"] }),
);
await handleCloudflareEmail(
makeMessage(),
env as any,
{ waitUntil: () => {} } as any,
);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
import PostalMime from "postal-mime";
import { Env } from "../types";
import { processEmail, RawAttachment } from "./email-processor";
export async function handleCloudflareEmail(
message: ForwardableEmailMessage,
env: Env,
ctx: ExecutionContext,
): Promise<void> {
try {
const email = await PostalMime.parse(message.raw);
const fromAddress = email.from?.address ?? message.from;
const from =
email.from?.name && email.from.address
? `${email.from.name} <${email.from.address}>`
: fromAddress;
const headers: Record<string, string> = {};
for (const h of email.headers) {
headers[h.key] = h.value;
}
const rawAttachments: RawAttachment[] = (email.attachments ?? [])
.filter((a) => a.content instanceof ArrayBuffer)
.map((a) => ({
filename: a.filename || "attachment",
contentType: a.mimeType || "application/octet-stream",
content: a.content as ArrayBuffer,
}));
await processEmail(
{
toAddress: message.to,
from,
senders: [message.from],
subject: email.subject ?? "(no subject)",
content: email.html ?? email.text ?? "",
receivedAt: email.date ? new Date(email.date).getTime() : Date.now(),
headers,
attachments: rawAttachments,
},
env,
ctx,
);
} catch (error) {
console.error("Error processing Cloudflare email:", error);
}
}
+469
View File
@@ -0,0 +1,469 @@
import { describe, it, expect, beforeEach } from "vitest";
import "../test/setup";
import { createMockEnv, MockR2 } from "../test/setup";
import {
processEmail,
ProcessEmailInput,
RawAttachment,
} from "./email-processor";
const VALID_FEED_ID = "apple.mountain.42";
const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`;
function makeInput(
overrides: Partial<ProcessEmailInput> = {},
): ProcessEmailInput {
return {
toAddress: VALID_TO,
from: "Sender <[email protected]>",
senders: ["[email protected]"],
subject: "Test Subject",
content: "<p>Hello</p>",
receivedAt: 1700000000000,
...overrides,
};
}
describe("processEmail", () => {
let env: ReturnType<typeof createMockEnv>;
beforeEach(() => {
env = createMockEnv();
});
it("returns 400 when toAddress has no valid feedId", async () => {
const res = await processEmail(
makeInput({ toAddress: "[email protected]" }),
env as any,
);
expect(res.status).toBe(400);
});
it("returns 404 when feed does not exist", async () => {
const res = await processEmail(makeInput(), env as any);
expect(res.status).toBe(404);
});
it("returns 403 when sender is not in allowlist", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["[email protected]"] }),
);
const res = await processEmail(
makeInput({ senders: ["[email protected]"] }),
env as any,
);
expect(res.status).toBe(403);
});
it("returns 200 and stores email when sender is allowed by exact match", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["[email protected]"] }),
);
const res = await processEmail(makeInput(), env as any);
expect(res.status).toBe(200);
});
it("returns 200 and stores email when sender matches by domain", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["example.com"] }),
);
const res = await processEmail(
makeInput({ senders: ["[email protected]"] }),
env as any,
);
expect(res.status).toBe(200);
});
it("returns 200 when no allowlist is set", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: [] }),
);
const res = await processEmail(makeInput(), env as any);
expect(res.status).toBe(200);
});
it("returns 403 when sender is in blocklist by exact address", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ blocked_senders: ["[email protected]"] }),
);
const res = await processEmail(makeInput(), env as any);
expect(res.status).toBe(403);
});
it("returns 403 when sender is in blocklist by domain", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ blocked_senders: ["example.com"] }),
);
const res = await processEmail(makeInput(), env as any);
expect(res.status).toBe(403);
});
it("returns 200 when sender is not in blocklist", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ blocked_senders: ["[email protected]"] }),
);
const res = await processEmail(makeInput(), env as any);
expect(res.status).toBe(200);
});
it("exact block takes precedence over domain allow", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({
allowed_senders: ["example.com"],
blocked_senders: ["[email protected]"],
}),
);
const res = await processEmail(makeInput(), env as any);
expect(res.status).toBe(403);
});
it("exact allow overrides domain block (exception use case)", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({
allowed_senders: ["[email protected]"],
blocked_senders: ["example.com"],
}),
);
const res = await processEmail(makeInput(), env as any);
expect(res.status).toBe(200);
});
it("exact block takes precedence over exact allow", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({
allowed_senders: ["[email protected]"],
blocked_senders: ["[email protected]"],
}),
);
const res = await processEmail(makeInput(), env as any);
expect(res.status).toBe(403);
});
it("stores email data and updates metadata in KV", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const input = makeInput({ subject: "My Subject", content: "<b>body</b>" });
await processEmail(input, env as any);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails).toHaveLength(1);
expect(metadata.emails[0].subject).toBe("My Subject");
const emailData = await env.EMAIL_STORAGE.get(
metadata.emails[0].key,
"json",
);
expect(emailData.subject).toBe("My Subject");
expect(emailData.content).toBe("<b>body</b>");
expect(emailData.from).toBe("Sender <[email protected]>");
});
it("prepends to existing metadata", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:metadata`,
JSON.stringify({
emails: [{ key: "old-key", subject: "Old", receivedAt: 1, size: 100 }],
}),
);
await processEmail(makeInput({ subject: "New" }), env as any);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails).toHaveLength(2);
expect(metadata.emails[0].subject).toBe("New");
expect(metadata.emails[1].subject).toBe("Old");
});
it("trims oldest emails when total size exceeds FEED_MAX_SIZE_BYTES", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const oldKey1 = `feed:${VALID_FEED_ID}:111`;
const oldKey2 = `feed:${VALID_FEED_ID}:222`;
const bigContent = "x".repeat(200);
const email1 = JSON.stringify({
subject: "Old1",
from: "[email protected]",
content: bigContent,
receivedAt: 111,
headers: {},
});
const email2 = JSON.stringify({
subject: "Old2",
from: "[email protected]",
content: bigContent,
receivedAt: 222,
headers: {},
});
await env.EMAIL_STORAGE.put(oldKey1, email1);
await env.EMAIL_STORAGE.put(oldKey2, email2);
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:metadata`,
JSON.stringify({
emails: [
{
key: oldKey2,
subject: "Old2",
receivedAt: 222,
size: email2.length,
},
{
key: oldKey1,
subject: "Old1",
receivedAt: 111,
size: email1.length,
},
],
}),
);
const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" };
const res = await processEmail(
makeInput({ subject: "New" }),
tinyEnv as any,
);
expect(res.status).toBe(200);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails).toHaveLength(1);
expect(metadata.emails[0].subject).toBe("New");
const deleted1 = await env.EMAIL_STORAGE.get(oldKey1, "json");
const deleted2 = await env.EMAIL_STORAGE.get(oldKey2, "json");
expect(deleted1).toBeNull();
expect(deleted2).toBeNull();
});
it("keeps entries within size budget untouched", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const bigEnv = { ...env, FEED_MAX_SIZE_BYTES: String(10 * 1024 * 1024) };
await processEmail(makeInput({ subject: "First" }), bigEnv as any);
await processEmail(makeInput({ subject: "Second" }), bigEnv as any);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails).toHaveLength(2);
});
it("calls ctx.waitUntil with notifySubscribers when ctx is provided", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({
title: "Test",
language: "en",
created_at: Date.now(),
}),
);
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:metadata`,
JSON.stringify({ emails: [] }),
);
let waitUntilCalled = false;
const ctx = {
waitUntil: (p: Promise<unknown>) => {
waitUntilCalled = true;
void p; // don't actually await it
},
passThroughOnException: () => {},
} as unknown as ExecutionContext;
const res = await processEmail(makeInput(), env as any, ctx);
expect(res.status).toBe(200);
expect(waitUntilCalled).toBe(true);
});
it("does not call ctx.waitUntil on error paths (feed not found)", async () => {
let waitUntilCalled = false;
const ctx = {
waitUntil: (p: Promise<unknown>) => {
waitUntilCalled = true;
void p;
},
passThroughOnException: () => {},
} as unknown as ExecutionContext;
// Feed ID is valid format but config doesn't exist → 404
const res = await processEmail(
makeInput({ toAddress: `[email protected]` }),
env as any,
ctx,
);
expect(res.status).toBe(404);
expect(waitUntilCalled).toBe(false);
});
});
describe("processEmail — attachments", () => {
const pdfContent = new TextEncoder().encode("PDF bytes")
.buffer as ArrayBuffer;
const pdfAttachment: RawAttachment = {
filename: "report.pdf",
contentType: "application/pdf",
content: pdfContent,
};
it("skips R2 upload when ATTACHMENT_BUCKET is not configured", async () => {
const env = createMockEnv();
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const res = await processEmail(
makeInput({ attachments: [pdfAttachment] }),
env as any,
);
expect(res.status).toBe(200);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
const emailData = await env.EMAIL_STORAGE.get(
metadata.emails[0].key,
"json",
);
expect(emailData.attachments).toBeUndefined();
});
it("uploads attachments to R2 and stores AttachmentData in emailData", async () => {
const env = createMockEnv({ withR2: true });
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const res = await processEmail(
makeInput({ attachments: [pdfAttachment] }),
env as any,
);
expect(res.status).toBe(200);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
const emailData = await env.EMAIL_STORAGE.get(
metadata.emails[0].key,
"json",
);
expect(emailData.attachments).toHaveLength(1);
expect(emailData.attachments[0].filename).toBe("report.pdf");
expect(emailData.attachments[0].contentType).toBe("application/pdf");
expect(emailData.attachments[0].size).toBe(pdfContent.byteLength);
const id = emailData.attachments[0].id;
expect(mockR2._has(id)).toBe(true);
});
it("stores attachmentIds in EmailMetadata for trim-time cleanup", async () => {
const env = createMockEnv({ withR2: true });
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
await processEmail(makeInput({ attachments: [pdfAttachment] }), env as any);
const metadata = await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
);
expect(metadata.emails[0].attachmentIds).toHaveLength(1);
expect(typeof metadata.emails[0].attachmentIds[0]).toBe("string");
});
it("deletes R2 objects when a trimmed email had attachments", async () => {
const env = createMockEnv({ withR2: true });
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
// Store an old email with attachment in KV and metadata
const oldKey = `feed:${VALID_FEED_ID}:111`;
const oldAttachmentId = "old-attachment-uuid";
const bigContent = "x".repeat(200);
const oldEmail = JSON.stringify({
subject: "Old",
from: "[email protected]",
content: bigContent,
receivedAt: 111,
headers: {},
attachments: [
{
id: oldAttachmentId,
filename: "old.pdf",
contentType: "application/pdf",
size: 100,
},
],
});
await env.EMAIL_STORAGE.put(oldKey, oldEmail);
// Also put the attachment in mock R2
await mockR2.put(oldAttachmentId, new ArrayBuffer(100));
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:metadata`,
JSON.stringify({
emails: [
{
key: oldKey,
subject: "Old",
receivedAt: 111,
size: oldEmail.length,
attachmentIds: [oldAttachmentId],
},
],
}),
);
// Process with tight size budget to force trimming
const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" };
const res = await processEmail(
makeInput({ subject: "New" }),
tinyEnv as any,
);
expect(res.status).toBe(200);
// Old attachment should be deleted from R2
expect(mockR2._has(oldAttachmentId)).toBe(false);
});
});
+245
View File
@@ -0,0 +1,245 @@
import { EmailParser } from "../utils/email-parser";
import {
AttachmentData,
EmailMetadata,
Env,
FeedConfig,
FeedMetadata,
} from "../types";
import { notifySubscribers } from "../utils/websub";
import { logger } from "./logger";
import { FEED_MAX_BYTES } from "../config/constants";
export interface RawAttachment {
filename: string;
contentType: string;
content: ArrayBuffer;
}
export interface ProcessEmailInput {
toAddress: string;
from: string;
senders: string[];
subject: string;
content: string;
receivedAt: number;
headers?: Record<string, string>;
attachments?: RawAttachment[];
}
type ValidationSuccess = { ok: true; feedId: string; feedConfig: FeedConfig };
type ValidationFailure = { ok: false; response: Response };
type ValidationResult = ValidationSuccess | ValidationFailure;
function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
type SenderDecision = "blocked" | "allowed" | "neutral";
function evaluateSender(
sender: string,
allowedSenders: string[],
blockedSenders: string[],
): SenderDecision {
const normalized = normalizeEmail(sender);
const domain = normalized.split("@")[1] || "";
const normalizeDomain = (e: string) => (e.startsWith("@") ? e.slice(1) : e);
const exactBlocked = blockedSenders.filter((e) => e.includes("@"));
const exactAllowed = allowedSenders.filter((e) => e.includes("@"));
const domainBlocked = blockedSenders
.filter((e) => !e.includes("@"))
.map(normalizeDomain);
const domainAllowed = allowedSenders
.filter((e) => !e.includes("@"))
.map(normalizeDomain);
if (exactBlocked.includes(normalized)) return "blocked";
if (exactAllowed.includes(normalized)) return "allowed";
if (domain && domainBlocked.includes(domain)) return "blocked";
if (domain && domainAllowed.includes(domain)) return "allowed";
return "neutral";
}
async function uploadAttachments(
attachments: RawAttachment[],
bucket: R2Bucket,
): Promise<AttachmentData[]> {
return Promise.all(
attachments.map(async (att) => {
const id = crypto.randomUUID();
await bucket.put(id, att.content, {
httpMetadata: {
contentType: att.contentType,
contentDisposition: `attachment; filename="${att.filename}"`,
},
});
return {
id,
filename: att.filename,
contentType: att.contentType,
size: att.content.byteLength,
};
}),
);
}
export async function validateEmail(
input: ProcessEmailInput,
env: Env,
): Promise<ValidationResult> {
const feedId = EmailParser.extractFeedId(input.toAddress);
if (!feedId) {
logger.error("Invalid email address format", {
toAddress: input.toAddress,
});
return {
ok: false,
response: new Response("Invalid email address format", { status: 400 }),
};
}
const feedConfig = (await env.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
)) as FeedConfig | null;
if (!feedConfig) {
logger.error("Feed not found", { feedId });
return {
ok: false,
response: new Response("Feed does not exist", { status: 404 }),
};
}
const allowedSenders = (feedConfig.allowed_senders || [])
.map(normalizeEmail)
.filter(Boolean);
const blockedSenders = (feedConfig.blocked_senders || [])
.map(normalizeEmail)
.filter(Boolean);
if (allowedSenders.length > 0 || blockedSenders.length > 0) {
const hasAllowlist = allowedSenders.length > 0;
const accepted = input.senders.some((sender) => {
const decision = evaluateSender(sender, allowedSenders, blockedSenders);
if (decision === "allowed") return true;
if (decision === "blocked") return false;
return !hasAllowlist;
});
if (!accepted) {
logger.warn("Rejected email: sender filter", {
feedId,
senders: input.senders,
allowedSenders,
blockedSenders,
});
return {
ok: false,
response: new Response("Sender not allowed for this feed", {
status: 403,
}),
};
}
}
return { ok: true, feedId, feedConfig };
}
export async function storeEmail(
feedId: string,
input: ProcessEmailInput,
env: Env,
ctx?: ExecutionContext,
): Promise<void> {
const storedAttachments: AttachmentData[] =
env.ATTACHMENT_BUCKET && input.attachments?.length
? await uploadAttachments(input.attachments, env.ATTACHMENT_BUCKET)
: [];
const emailData = {
subject: input.subject,
from: input.from,
content: input.content,
receivedAt: input.receivedAt,
headers: input.headers ?? {},
...(storedAttachments.length > 0 ? { attachments: storedAttachments } : {}),
};
const emailKey = `feed:${feedId}:${Date.now()}`;
const feedMetadataKey = `feed:${feedId}:metadata`;
const [, rawMetadata] = await Promise.all([
env.EMAIL_STORAGE.put(emailKey, JSON.stringify(emailData)),
env.EMAIL_STORAGE.get(feedMetadataKey, "json"),
]);
// Note: KV has no atomic compare-and-swap. Concurrent invocations for the
// same feed can read stale metadata and produce orphaned KV entries or
// duplicate trim deletions. This is an accepted limitation given Cloudflare
// KV's eventual-consistency model.
// TODO: Migrate feed metadata writes to Cloudflare Durable Objects to serialise
// concurrent writes and eliminate this race condition.
const feedMetadata = ((rawMetadata as FeedMetadata | null) || {
emails: [],
}) as FeedMetadata;
const maxBytes =
parseInt(env.FEED_MAX_SIZE_BYTES ?? "", 10) || FEED_MAX_BYTES;
const serialised = JSON.stringify(emailData);
const serialisedSize = new TextEncoder().encode(serialised).byteLength;
const newEntry: EmailMetadata = {
key: emailKey,
subject: emailData.subject,
receivedAt: emailData.receivedAt,
size: serialisedSize,
...(storedAttachments.length > 0
? { attachmentIds: storedAttachments.map((a) => a.id) }
: {}),
};
feedMetadata.emails.unshift(newEntry);
let totalSize = feedMetadata.emails.reduce(
(sum, e) => sum + (e.size ?? 0),
0,
);
const toDelete: EmailMetadata[] = [];
while (totalSize > maxBytes && feedMetadata.emails.length > 1) {
const dropped = feedMetadata.emails.pop()!;
totalSize -= dropped.size ?? 0;
toDelete.push(dropped);
}
const r2Deletions =
env.ATTACHMENT_BUCKET && toDelete.length > 0
? toDelete
.flatMap((e) => e.attachmentIds ?? [])
.map((id) => env.ATTACHMENT_BUCKET!.delete(id))
: [];
await Promise.all([
env.EMAIL_STORAGE.put(feedMetadataKey, JSON.stringify(feedMetadata)),
...toDelete.map((e) => env.EMAIL_STORAGE.delete(e.key)),
...r2Deletions,
]);
logger.info("Email processed", { feedId });
if (ctx) {
ctx.waitUntil(notifySubscribers(feedId, env));
}
}
export async function processEmail(
input: ProcessEmailInput,
env: Env,
ctx?: ExecutionContext,
): Promise<Response> {
const validation = await validateEmail(input, env);
if (!validation.ok) return validation.response;
await storeEmail(validation.feedId, input, env, ctx);
return new Response("Email processed successfully", { status: 200 });
}
+101
View File
@@ -0,0 +1,101 @@
import { EmailParser } from "../utils/email-parser";
import { Env } from "../types";
import { processEmail, RawAttachment } from "./email-processor";
export interface ForwardEmailAttachment {
filename?: string;
contentType?: string;
size?: number;
content?: { type: "Buffer"; data: number[] } | ArrayBuffer | ArrayBufferView;
}
export interface ForwardEmailPayload {
recipients?: string[];
from?: {
value?: Array<{ address?: string; name?: string }>;
text?: string;
html?: string;
};
subject?: string;
text?: string;
html?: string;
date?: string;
messageId?: string;
headerLines?: Array<{ key: string; line: string }>;
headers?: string;
raw?: string;
attachments?: ForwardEmailAttachment[];
}
function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
function extractSenderAddresses(payload: ForwardEmailPayload): string[] {
const valueEntries = payload.from?.value || [];
const structuredAddresses = valueEntries
.map((entry) => entry.address || "")
.map(normalizeEmail)
.filter(Boolean);
if (structuredAddresses.length > 0) {
return Array.from(new Set(structuredAddresses));
}
const fromText = payload.from?.text || "";
const matches =
fromText.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || [];
return Array.from(new Set(matches.map(normalizeEmail)));
}
function toArrayBuffer(
content: ForwardEmailAttachment["content"],
): ArrayBuffer | null {
if (!content) return null;
if (content instanceof ArrayBuffer) return content;
if (ArrayBuffer.isView(content))
return (content as ArrayBufferView).buffer as ArrayBuffer;
if (
typeof content === "object" &&
content.type === "Buffer" &&
Array.isArray(content.data)
) {
return Uint8Array.from(content.data).buffer as ArrayBuffer;
}
return null;
}
export async function handleForwardEmail(
payload: ForwardEmailPayload,
env: Env,
ctx?: ExecutionContext,
): Promise<Response> {
const emailData = EmailParser.parseForwardEmailPayload(payload);
const rawAttachments: RawAttachment[] = (payload.attachments ?? [])
.map((a) => {
const buffer = toArrayBuffer(a.content);
if (!buffer) return null;
return {
filename: a.filename || "attachment",
contentType: a.contentType || "application/octet-stream",
content: buffer,
};
})
.filter((a): a is RawAttachment => a !== null);
return processEmail(
{
toAddress: payload.recipients?.[0] || "",
from: emailData.from,
senders: extractSenderAddresses(payload),
subject: emailData.subject,
content: emailData.content,
receivedAt: emailData.receivedAt,
headers: emailData.headers,
attachments: rawAttachments,
},
env,
ctx,
);
}
+26
View File
@@ -0,0 +1,26 @@
type LogLevel = "info" | "warn" | "error" | "debug";
function log(
level: LogLevel,
message: string,
data?: Record<string, unknown>,
): void {
const entry = data ? { level, message, ...data } : { level, message };
const line = JSON.stringify(entry);
if (level === "error" || level === "warn") {
console.error(line);
} else {
console.log(line);
}
}
export const logger = {
info: (message: string, data?: Record<string, unknown>) =>
log("info", message, data),
warn: (message: string, data?: Record<string, unknown>) =>
log("warn", message, data),
error: (message: string, data?: Record<string, unknown>) =>
log("error", message, data),
debug: (message: string, data?: Record<string, unknown>) =>
log("debug", message, data),
};
+290 -24
View File
@@ -11,10 +11,11 @@ describe("Admin Routes", () => {
let loginAndGetCookie: () => Promise<string>; let loginAndGetCookie: () => Promise<string>;
beforeEach(() => { beforeEach(() => {
mockEnv = createMockEnv(); mockEnv = createMockEnv() as unknown as Env;
testApp = new Hono(); testApp = new Hono();
testApp.route("/admin", app); testApp.route("/admin", app);
request = (path, init = {}) => testApp.request(path, init, mockEnv); request = (path, init = {}) =>
Promise.resolve(testApp.request(path, init, mockEnv));
loginAndGetCookie = async () => { loginAndGetCookie = async () => {
const formData = new FormData(); const formData = new FormData();
formData.append("password", "test-password"); formData.append("password", "test-password");
@@ -93,6 +94,7 @@ describe("Admin Routes", () => {
const res = await request("/admin", { const res = await request("/admin", {
headers: { headers: {
Cookie: authCookie, Cookie: authCookie,
Origin: "https://test.getmynews.app",
}, },
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -138,6 +140,7 @@ describe("Admin Routes", () => {
method: "POST", method: "POST",
headers: { headers: {
Cookie: authCookie, Cookie: authCookie,
Origin: "https://test.getmynews.app",
}, },
body: formData, body: formData,
}); });
@@ -161,8 +164,8 @@ describe("Admin Routes", () => {
"json", "json",
); );
expect(feedConfig).toBeTruthy(); expect(feedConfig).toBeTruthy();
expect(feedConfig.title).toBe("Test Feed"); expect((feedConfig as any).title).toBe("Test Feed");
expect(feedConfig.description).toBe("Test Description"); expect((feedConfig as any).description).toBe("Test Description");
}); });
it("should reject feed creation with missing title", async () => { it("should reject feed creation with missing title", async () => {
@@ -174,6 +177,7 @@ describe("Admin Routes", () => {
method: "POST", method: "POST",
headers: { headers: {
Cookie: authCookie, Cookie: authCookie,
Origin: "https://test.getmynews.app",
}, },
body: formData, body: formData,
}); });
@@ -186,6 +190,24 @@ describe("Admin Routes", () => {
}); });
}); });
describe("API Feed Update", () => {
it("returns 400 with structured validation error for empty title", async () => {
const authCookie = await loginAndGetCookie();
const res = await request("/admin/api/feeds/test-feed/update", {
method: "POST",
headers: {
Cookie: authCookie,
"Content-Type": "application/json",
Origin: "https://test.getmynews.app",
},
body: JSON.stringify({ title: "", description: "desc" }),
});
expect(res.status).toBe(400);
const body = await res.json<{ success: boolean }>();
expect(body.success).toBe(false);
});
});
describe("Feed Management", () => { describe("Feed Management", () => {
it("should prevent feed deletion without authentication", async () => { it("should prevent feed deletion without authentication", async () => {
const res = await request("/admin/feeds/test-feed/delete", { const res = await request("/admin/feeds/test-feed/delete", {
@@ -223,6 +245,7 @@ describe("Admin Routes", () => {
method: "POST", method: "POST",
headers: { headers: {
Cookie: authCookie, Cookie: authCookie,
Origin: "https://test.getmynews.app",
}, },
body: formData, body: formData,
}); });
@@ -241,6 +264,7 @@ describe("Admin Routes", () => {
method: "POST", method: "POST",
headers: { headers: {
Cookie: authCookie, Cookie: authCookie,
Origin: "https://test.getmynews.app",
}, },
}); });
@@ -273,6 +297,7 @@ describe("Admin Routes", () => {
method: "POST", method: "POST",
headers: { headers: {
Cookie: authCookie, Cookie: authCookie,
Origin: "https://test.getmynews.app",
}, },
body: formData, body: formData,
}); });
@@ -285,16 +310,20 @@ describe("Admin Routes", () => {
)) as { feeds: Array<{ id: string; title: string }> } | null; )) as { feeds: Array<{ id: string; title: string }> } | null;
const feedId = feedList?.feeds[0].id as string; const feedId = feedList?.feeds[0].id as string;
const deleteRes = await request(`/admin/feeds/${feedId}/delete?view=list`, { const deleteRes = await request(
method: "POST", `/admin/feeds/${feedId}/delete?view=list`,
headers: { {
Cookie: authCookie, method: "POST",
Accept: "application/json", headers: {
Cookie: authCookie,
Accept: "application/json",
Origin: "https://test.getmynews.app",
},
}, },
}); );
expect(deleteRes.status).toBe(200); expect(deleteRes.status).toBe(200);
const payload = await deleteRes.json(); const payload = (await deleteRes.json()) as any;
expect(payload.ok).toBe(true); expect(payload.ok).toBe(true);
expect(payload.feedId).toBe(feedId); expect(payload.feedId).toBe(feedId);
}); });
@@ -308,7 +337,10 @@ describe("Admin Routes", () => {
formData.append("description", "Test"); formData.append("description", "Test");
const createRes = await request("/admin/feeds/create", { const createRes = await request("/admin/feeds/create", {
method: "POST", method: "POST",
headers: { Cookie: authCookie }, headers: {
Cookie: authCookie,
Origin: "https://test.getmynews.app",
},
body: formData, body: formData,
}); });
expect(createRes.status).toBe(302); expect(createRes.status).toBe(302);
@@ -329,13 +361,17 @@ describe("Admin Routes", () => {
const bulkDeleteRes = await request("/admin/feeds/bulk-delete", { const bulkDeleteRes = await request("/admin/feeds/bulk-delete", {
method: "POST", method: "POST",
headers: { Cookie: authCookie }, headers: { Cookie: authCookie, Origin: "https://test.getmynews.app" },
body: bulkForm, body: bulkForm,
}); });
expect(bulkDeleteRes.status).toBe(302); expect(bulkDeleteRes.status).toBe(302);
expect(bulkDeleteRes.headers.get("Location")).toContain("/admin?view=list"); expect(bulkDeleteRes.headers.get("Location")).toContain(
expect(bulkDeleteRes.headers.get("Location")).toContain("message=bulkDeleted"); "/admin?view=list",
);
expect(bulkDeleteRes.headers.get("Location")).toContain(
"message=bulkDeleted",
);
const feedListAfter = (await mockEnv.EMAIL_STORAGE.get( const feedListAfter = (await mockEnv.EMAIL_STORAGE.get(
"feeds:list", "feeds:list",
@@ -347,6 +383,102 @@ describe("Admin Routes", () => {
}); });
}); });
describe("Proxy authentication", () => {
const TRUSTED_IP = "10.0.0.1";
const PROXY_SECRET = "my-proxy-secret";
function proxyEnv() {
return {
...createMockEnv(),
PROXY_TRUSTED_IPS: TRUSTED_IP,
PROXY_AUTH_SECRET: PROXY_SECRET,
} as unknown as Env;
}
function makeProxyRequest(
path: string,
headers: Record<string, string> = {},
) {
const proxyApp = new Hono();
proxyApp.route("/admin", app);
return proxyApp.request(path, { headers }, proxyEnv());
}
it("grants access when IP, secret and Remote-User are all valid", async () => {
const res = await makeProxyRequest("/admin", {
"CF-Connecting-IP": TRUSTED_IP,
"X-Auth-Proxy-Secret": PROXY_SECRET,
"Remote-User": "alice",
});
expect(res.status).toBe(200);
});
it("grants access using X-Forwarded-User instead of Remote-User", async () => {
const res = await makeProxyRequest("/admin", {
"CF-Connecting-IP": TRUSTED_IP,
"X-Auth-Proxy-Secret": PROXY_SECRET,
"X-Forwarded-User": "bob",
});
expect(res.status).toBe(200);
});
it("rejects when IP is not in trusted list", async () => {
const res = await makeProxyRequest("/admin", {
"CF-Connecting-IP": "1.2.3.4",
"X-Auth-Proxy-Secret": PROXY_SECRET,
"Remote-User": "alice",
});
expect(res.status).toBe(302);
});
it("rejects when secret is wrong", async () => {
const res = await makeProxyRequest("/admin", {
"CF-Connecting-IP": TRUSTED_IP,
"X-Auth-Proxy-Secret": "wrong-secret",
"Remote-User": "alice",
});
expect(res.status).toBe(302);
});
it("rejects when Remote-User is missing", async () => {
const res = await makeProxyRequest("/admin", {
"CF-Connecting-IP": TRUSTED_IP,
"X-Auth-Proxy-Secret": PROXY_SECRET,
});
expect(res.status).toBe(302);
});
it("falls back to cookie auth when proxy env vars are not configured", async () => {
const res = await request("/admin");
expect(res.status).toBe(302);
expect(res.headers.get("Location")).toBe("/admin/login");
});
it("falls back to cookie auth when only one proxy env var is configured", async () => {
const partialProxyApp = new Hono();
partialProxyApp.route("/admin", app);
const partialEnv = {
...createMockEnv(),
PROXY_TRUSTED_IPS: "10.0.0.1",
// PROXY_AUTH_SECRET intentionally absent
} as unknown as Env;
const res = await partialProxyApp.request(
"/admin",
{
headers: {
"CF-Connecting-IP": "10.0.0.1",
"Remote-User": "alice",
// No X-Auth-Proxy-Secret — proxy auth should NOT activate
},
},
partialEnv,
);
expect(res.status).toBe(302);
expect(res.headers.get("Location")).toBe("/admin/login");
});
});
describe("Email Management", () => { describe("Email Management", () => {
it("should return JSON for email deletion when requested", async () => { it("should return JSON for email deletion when requested", async () => {
const authCookie = await loginAndGetCookie(); const authCookie = await loginAndGetCookie();
@@ -358,6 +490,7 @@ describe("Admin Routes", () => {
method: "POST", method: "POST",
headers: { headers: {
Cookie: authCookie, Cookie: authCookie,
Origin: "https://test.getmynews.app",
}, },
body: formData, body: formData,
}); });
@@ -386,7 +519,9 @@ describe("Admin Routes", () => {
const feedMetadata = (await mockEnv.EMAIL_STORAGE.get( const feedMetadata = (await mockEnv.EMAIL_STORAGE.get(
feedMetadataKey, feedMetadataKey,
"json", "json",
)) as { emails: Array<{ key: string; subject: string; receivedAt: number }> } | null; )) as {
emails: Array<{ key: string; subject: string; receivedAt: number }>;
} | null;
const updatedMetadata = { const updatedMetadata = {
emails: [ emails: [
...(feedMetadata?.emails || []), ...(feedMetadata?.emails || []),
@@ -398,16 +533,20 @@ describe("Admin Routes", () => {
JSON.stringify(updatedMetadata), JSON.stringify(updatedMetadata),
); );
const deleteRes = await request(`/admin/emails/${emailKey}/delete?feedId=${feedId}`, { const deleteRes = await request(
method: "POST", `/admin/emails/${emailKey}/delete?feedId=${feedId}`,
headers: { {
Cookie: authCookie, method: "POST",
Accept: "application/json", headers: {
Cookie: authCookie,
Accept: "application/json",
Origin: "https://test.getmynews.app",
},
}, },
}); );
expect(deleteRes.status).toBe(200); expect(deleteRes.status).toBe(200);
const payload = await deleteRes.json(); const payload = (await deleteRes.json()) as any;
expect(payload.ok).toBe(true); expect(payload.ok).toBe(true);
expect(payload.emailKey).toBe(emailKey); expect(payload.emailKey).toBe(emailKey);
@@ -417,9 +556,136 @@ describe("Admin Routes", () => {
const metadataAfter = (await mockEnv.EMAIL_STORAGE.get( const metadataAfter = (await mockEnv.EMAIL_STORAGE.get(
feedMetadataKey, feedMetadataKey,
"json", "json",
)) as { emails: Array<{ key: string; subject: string; receivedAt: number }> } | null; )) as {
emails: Array<{ key: string; subject: string; receivedAt: number }>;
} | null;
expect(metadataAfter?.emails.length).toBe(0); expect(metadataAfter?.emails.length).toBe(0);
}); });
}); });
}); });
describe("Sender filter", () => {
let authCookie: string;
let feedId: string;
beforeEach(async () => {
authCookie = await loginAndGetCookie();
const createRes = await request("/admin/feeds/create", {
method: "POST",
headers: {
Cookie: authCookie,
"Content-Type": "application/json",
Origin: "https://test.getmynews.app",
},
body: JSON.stringify({ title: "Filter Test Feed" }),
});
const payload = (await createRes.json()) as { feedId: string };
feedId = payload.feedId;
});
const post = (body: object, cookie = authCookie) =>
request(`/admin/feeds/${feedId}/sender-filter`, {
method: "POST",
headers: {
Cookie: cookie,
"Content-Type": "application/json",
Origin: "https://test.getmynews.app",
},
body: JSON.stringify(body),
});
it("adds exact email to allowlist", async () => {
const res = await post({
action: "allow_sender",
value: "[email protected]",
});
expect(res.status).toBe(200);
expect(((await res.json()) as any).ok).toBe(true);
const cfg = (await mockEnv.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
)) as any;
expect(cfg.allowed_senders).toContain("[email protected]");
});
it("adds domain to allowlist", async () => {
const res = await post({ action: "allow_domain", value: "example.com" });
expect(res.status).toBe(200);
const cfg = (await mockEnv.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
)) as any;
expect(cfg.allowed_senders).toContain("example.com");
});
it("adds exact email to blocklist", async () => {
const res = await post({ action: "block_sender", value: "[email protected]" });
expect(res.status).toBe(200);
const cfg = (await mockEnv.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
)) as any;
expect(cfg.blocked_senders).toContain("[email protected]");
});
it("adds domain to blocklist", async () => {
const res = await post({ action: "block_domain", value: "bad.com" });
expect(res.status).toBe(200);
const cfg = (await mockEnv.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
)) as any;
expect(cfg.blocked_senders).toContain("bad.com");
});
it("returns 409 when value already exists in the opposite list", async () => {
await post({ action: "block_sender", value: "[email protected]" });
const res = await post({
action: "allow_sender",
value: "[email protected]",
});
expect(res.status).toBe(409);
const data = (await res.json()) as any;
expect(data.ok).toBe(false);
expect(data.error).toMatch(/blocklist/);
});
it("is idempotent when value already in target list", async () => {
await post({ action: "allow_sender", value: "[email protected]" });
const res = await post({
action: "allow_sender",
value: "[email protected]",
});
expect(res.status).toBe(200);
const cfg = (await mockEnv.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
)) as any;
expect(
cfg.allowed_senders.filter((s: string) => s === "[email protected]")
.length,
).toBe(1);
});
it("returns 400 for invalid action", async () => {
const res = await post({
action: "invalid_action",
value: "[email protected]",
});
expect(res.status).toBe(400);
});
it("normalizes value to lowercase", async () => {
const res = await post({
action: "allow_sender",
value: "[email protected]",
});
expect(res.status).toBe(200);
const cfg = (await mockEnv.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
)) as any;
expect(cfg.allowed_senders).toContain("[email protected]");
});
});
}); });
-3554
View File
File diff suppressed because it is too large. Load diff
+914
View File
@@ -0,0 +1,914 @@
import { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { Env, FeedConfig } from "../types";
import { csrf } from "hono/csrf";
import { ADMIN_COOKIE_MAX_AGE } from "../config/constants";
import { logger } from "../lib/logger";
import { Layout, clampText } from "./admin/ui";
import { listAllFeeds, updateFeedInList } from "./admin/helpers";
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../utils/urls";
import { feedsRouter } from "./admin/feeds";
import { emailsRouter } from "./admin/emails";
import { dashboardScript } from "../scripts/generated/dashboard";
type AppEnv = { Bindings: Env };
/**
* Admin routes handler for Email-to-RSS
* Provides a secure interface for managing RSS feeds and viewing emails
*
* Security:
* - All routes except /login are protected by server-side cookie authentication
* - Uses HttpOnly cookies to prevent XSS attacks
* - Implements SameSite=Strict to prevent CSRF attacks
*/
const app = new Hono<AppEnv>();
// Export for testing
export default app;
const ADMIN_COOKIE_NAME = "admin_auth";
// Prevent accidental caching of admin pages and redirects.
app.use("*", async (c, next) => {
c.header("Cache-Control", "no-store, max-age=0");
await next();
});
function timingSafeEqual(a: string, b: string): boolean {
const enc = new TextEncoder();
const aBytes = enc.encode(a);
const bBytes = enc.encode(b);
// Try native timing-safe implementation first (Cloudflare Workers runtime)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const subtle = crypto.subtle as any;
if (typeof subtle.timingSafeEqual === "function") {
if (aBytes.length !== bBytes.length) return false;
return subtle.timingSafeEqual(aBytes, bBytes);
}
// Constant-time fallback for Node (test environment): encode length
// mismatch into `diff` so the loop always runs over the full length.
const len = Math.max(aBytes.length, bBytes.length);
let diff = aBytes.length ^ bBytes.length;
for (let i = 0; i < len; i++) {
diff |= (aBytes[i] ?? 0) ^ (bBytes[i] ?? 0);
}
return diff === 0;
}
// Authentication middleware for admin routes
async function authMiddleware(c: Context, next: () => Promise<void>) {
const env = c.env;
const path = new URL(c.req.url).pathname;
// Skip auth check for login page - note that path includes /admin prefix
if (path === "/admin/login") {
return next();
}
// Proxy auth: only active when both env vars are present
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
const trustedIps = env.PROXY_TRUSTED_IPS.split(",")
.map((s: string) => s.trim())
.filter(Boolean);
const clientIp = c.req.header("CF-Connecting-IP") ?? "";
const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? "";
const remoteUser =
c.req.header("Remote-User") || c.req.header("X-Forwarded-User") || "";
if (
trustedIps.includes(clientIp) &&
timingSafeEqual(providedSecret, env.PROXY_AUTH_SECRET) &&
remoteUser.length > 0
) {
return next();
}
}
// Fallback: signed cookie
const authCookie = await getSignedCookie(
c,
env.ADMIN_PASSWORD,
ADMIN_COOKIE_NAME,
);
if (authCookie !== "1") {
return c.redirect("/admin/login");
}
await next();
}
// Apply auth middleware to all admin routes
app.use("*", authMiddleware);
// CSRF middleware: validates Origin header on mutating requests (POST/PUT/DELETE/PATCH)
// Skip on /admin/login — password itself provides protection for the pre-auth route
const csrfMiddleware = csrf({
origin: (origin, c) => origin === `https://${c.env.DOMAIN}`,
});
app.use("*", (c, next) => {
const path = new URL(c.req.url).pathname;
if (path === "/admin/login") return next();
return csrfMiddleware(c, next);
});
// Schema for feed API updates (title/description only)
const updateFeedSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
language: z.string().optional().default("en"),
allowedSenders: z.array(z.string()).optional().default([]),
});
// Authentication schema
const authSchema = z.object({
password: z.string().min(1, "Password is required"),
});
// Login page
app.get("/login", (c) => {
const error = c.req.query("error");
const errorMessage =
error === "invalid" ? "Invalid password. Please try again." : "";
return c.html(
<Layout title="Login">
<div class="auth-container fade-in">
<div class="auth-card">
<div class="auth-logo">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="24" height="24" rx="12" fill="var(--color-primary)" />
<path
d="M17 9C17 7.89543 16.1046 7 15 7H9C7.89543 7 7 7.89543 7 9V15C7 16.1046 7.89543 17 9 17H15C16.1046 17 17 16.1046 17 15V9Z"
stroke="white"
stroke-width="1.5"
/>
<path
d="M7 9L12 13L17 9"
stroke="white"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<h1 class="auth-title">kill-the-news</h1>
{errorMessage && (
<div class="auth-error">{errorMessage}</div>
)}
<form class="auth-form" action="/admin/login" method="post">
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
autofocus
/>
</div>
<button type="submit" class="button auth-button">
Log In
</button>
</form>
</div>
</div>
</Layout>,
);
});
// Handle login
app.post("/login", async (c) => {
const env = c.env;
try {
const formData = await c.req.formData();
const password = formData.get("password")?.toString() || "";
// Validate password
authSchema.parse({ password });
// Check password against environment variable
if (timingSafeEqual(password, env.ADMIN_PASSWORD)) {
await setSignedCookie(c, ADMIN_COOKIE_NAME, "1", env.ADMIN_PASSWORD, {
path: "/",
httpOnly: true,
sameSite: "Strict",
secure: true,
maxAge: ADMIN_COOKIE_MAX_AGE,
});
return c.redirect("/admin");
}
// Incorrect password - redirect back to login with an error message
return c.redirect("/admin/login?error=invalid");
} catch (error) {
logger.error("Login error", { error: String(error) });
return c.redirect("/admin/login?error=invalid");
}
});
// Logout route
app.get("/logout", (c) => {
deleteCookie(c, ADMIN_COOKIE_NAME, { path: "/" });
return c.redirect("/admin/login");
});
// dashboardScript is compiled from src/scripts/client/dashboard.ts via `npm run build:client`.
// It is imported from src/scripts/generated/dashboard.ts above.
// ── Shared SVG icons ──────────────────────────────────────────────────────────
const CopyIcon = () => (
<svg
class="copy-icon copy-icon-original"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
);
const CheckIcon = () => (
<svg
class="copy-icon copy-icon-success"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6L9 17l-5-5"></path>
</svg>
);
type CopyFieldInlineProps = {
value: string;
emailAddress?: string;
};
const CopyFieldInline = ({ value }: CopyFieldInlineProps) => (
<div class="copyable copyable-inline">
<div class="copyable-content">
<span class="copyable-value" data-copy={value} title={value}>
{value}
</span>
<div class="copy-icon-container">
<CopyIcon />
<CheckIcon />
</div>
</div>
</div>
);
// Admin dashboard route
app.get("/", async (c) => {
// Type assertion for environment variables
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const url = new URL(c.req.url);
const view = url.searchParams.get("view") === "table" ? "table" : "list";
const message = url.searchParams.get("message");
const count = Number(url.searchParams.get("count") || "0");
// List all feeds
const feedList = await listAllFeeds(emailStorage);
// Keep the dashboard fast: avoid N KV reads for N feeds.
// We store title/description in `feeds:list` (description is optional for older data).
const feedsWithConfig = feedList.map((feed) => ({
...feed,
description: feed.description || "",
}));
const listHref = (() => {
const nextUrl = new URL(url);
nextUrl.pathname = "/admin";
nextUrl.searchParams.set("view", "list");
const qs = nextUrl.searchParams.toString();
return `${nextUrl.pathname}${qs ? `?${qs}` : ""}`;
})();
const tableHref = (() => {
const nextUrl = new URL(url);
nextUrl.pathname = "/admin";
nextUrl.searchParams.set("view", "table");
const qs = nextUrl.searchParams.toString();
return `${nextUrl.pathname}${qs ? `?${qs}` : ""}`;
})();
const viewToggle = (
<div class="segmented" role="tablist" aria-label="Feed view">
<a
class={`segmented-item ${view === "list" ? "is-active" : ""}`}
href={listHref}
role="tab"
aria-selected={view === "list" ? "true" : "false"}
>
List
</a>
<a
class={`segmented-item ${view === "table" ? "is-active" : ""}`}
href={tableHref}
role="tab"
aria-selected={view === "table" ? "true" : "false"}
>
Table
</a>
</div>
);
return c.html(
<Layout title="Dashboard">
<div class="container fade-in">
<div class="header-with-actions">
<div class="header-title">
<h1>kill-the-news</h1>
<p>Manage your email newsletter feeds</p>
</div>
<div class="header-actions">
<a href="/admin/logout" class="button button-logout">
Logout
</a>
</div>
</div>
<div class="card">
<h2>Create New Feed</h2>
<form action="/admin/feeds/create" method="post">
<div class="form-group">
<label for="title">Feed Title</label>
<input type="text" id="title" name="title" required />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows={3}></textarea>
</div>
<div class="form-group">
<label for="allowed_senders">
Allowed senders (optional, one email or domain per line)
</label>
<textarea
id="allowed_senders"
name="allowed_senders"
rows={3}
placeholder={"[email protected]\ntechmeme.com"}
></textarea>
<small>
When set, inbound emails are only accepted from these
senders/domains.
</small>
</div>
<div class="form-group">
<label for="blocked_senders">
Blocked senders (optional, one email or domain per line)
</label>
<textarea
id="blocked_senders"
name="blocked_senders"
rows={3}
placeholder={"[email protected]\nunwanted.com"}
></textarea>
<small>
Emails from these senders/domains are always rejected, even if
they match the allowlist.
</small>
</div>
<input type="hidden" id="language" name="language" value="en" />
<input type="hidden" name="view" value={view} />
<button type="submit" class="button">
Create Feed
</button>
</form>
</div>
{message === "bulkDeleted" && (
<div class="card">
<p>Deleted {Number.isFinite(count) ? count : 0} feed(s).</p>
</div>
)}
{message === "bulkDeleteNoop" && (
<div class="card">
<p>No feeds were selected.</p>
</div>
)}
<div class="toolbar">
<div class="toolbar-group">
<h2 style="margin: 0;">Your Feeds</h2>
<span class="pill" id="feed-total-count">
{feedsWithConfig.length}
</span>
</div>
<div class="toolbar-group">{viewToggle}</div>
</div>
{feedsWithConfig.length === 0 ? (
<div class="card">
<p>You don't have any feeds yet. Create one above.</p>
</div>
) : view === "table" ? (
<div class="card">
<form
id="bulk-feed-delete-form"
action="/admin/feeds/bulk-delete"
method="post"
onsubmit="return onBulkFeedDeleteSubmit(event)"
>
<input type="hidden" name="view" value="table" />
<div class="toolbar">
<div class="toolbar-group toolbar-group-fill">
<input
type="search"
id="feed-search"
class="search"
placeholder="Search title, feed id, or description"
oninput="scheduleFeedFilter()"
/>
<span class="pill" id="feed-match-count">
Showing {feedsWithConfig.length}
</span>
<span class="pill" id="selected-feed-count">
0 selected
</span>
<button
type="button"
class="button button-small button-secondary"
onclick="selectMatchingFeeds()"
>
Select Results
</button>
<button
type="button"
class="button button-small button-secondary"
onclick="clearFeedSelection()"
>
Clear Selection
</button>
<button
id="bulk-delete-feeds-button"
type="submit"
class="button button-small button-danger"
disabled
>
Delete Selected
</button>
</div>
</div>
<div class="table-wrap">
<table class="table table-feeds">
<colgroup>
<col data-col="select" style="width: 44px;" />
<col data-col="title" style="width: 280px;" />
<col data-col="feedId" style="width: 150px;" />
<col data-col="email" style="width: 200px;" />
<col data-col="rss" style="width: 190px;" />
<col data-col="atom" style="width: 190px;" />
<col data-col="actions" style="width: 170px;" />
</colgroup>
<thead>
<tr>
<th>
<input
type="checkbox"
id="select-all-feeds"
onchange="toggleAllFeeds(this.checked)"
/>
</th>
<th
class="th-resizable"
data-sort-key="title"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="title"
>
Title
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<div
class="col-resizer"
data-col="title"
title="Resize"
></div>
</th>
<th
class="th-resizable"
data-sort-key="feedId"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="feedId"
>
Feed ID
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<div
class="col-resizer"
data-col="feedId"
title="Resize"
></div>
</th>
<th
class="th-resizable"
data-sort-key="email"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="email"
>
Email
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<div
class="col-resizer"
data-col="email"
title="Resize"
></div>
</th>
<th
class="th-resizable"
data-sort-key="rss"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="rss"
>
RSS
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<div
class="col-resizer"
data-col="rss"
title="Resize"
></div>
</th>
<th class="th-resizable" data-sort-key="atom" aria-sort="none">
<button
type="button"
class="th-button"
data-sort-key="atom"
>
Atom
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<div
class="col-resizer"
data-col="atom"
title="Resize"
></div>
</th>
<th class="th-resizable">
<span>Actions</span>
<div
class="col-resizer"
data-col="actions"
title="Resize"
></div>
</th>
</tr>
</thead>
<tbody id="feed-table-body">
{feedsWithConfig.map((feed) => {
const emailAddress = feedEmailAddress(feed.id, env);
const rssUrl = feedRssUrl(feed.id, env);
const atomUrl = feedAtomUrl(feed.id, env);
const titleDisplay = clampText(feed.title, 160);
const titleHover = clampText(feed.title, 1000);
const sortTitle = titleHover.toLowerCase();
const sortFeedId = feed.id.toLowerCase();
const sortEmail = emailAddress.toLowerCase();
const sortRss = rssUrl.toLowerCase();
const sortAtom = atomUrl.toLowerCase();
const descDisplay = clampText(feed.description || "", 220);
const descHover = clampText(feed.description || "", 1000);
const searchHaystack =
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
return (
<tr
class="feed-row"
data-feed-id={feed.id}
data-search={searchHaystack}
data-sort-title={sortTitle}
data-sort-feed-id={sortFeedId}
data-sort-email={sortEmail}
data-sort-rss={sortRss}
data-sort-atom={sortAtom}
>
<td>
<input
type="checkbox"
class="feed-select"
name="feedIds"
value={feed.id}
onchange="updateFeedSelectionState()"
/>
</td>
<td>
<strong class="truncate" title={titleHover}>
{titleDisplay}
</strong>
{feed.description && (
<div
class="muted truncate"
style="font-size: var(--font-size-sm); margin-top: 4px;"
title={descHover}
>
{descDisplay}
</div>
)}
</td>
<td>
<code>{feed.id}</code>
</td>
<td>
<CopyFieldInline value={emailAddress} />
</td>
<td>
<CopyFieldInline value={rssUrl} />
</td>
<td>
<CopyFieldInline value={atomUrl} />
</td>
<td>
<div class="row-actions">
<a
href={`/admin/feeds/${feed.id}/edit`}
class="button button-small"
>
Edit
</a>
<a
href={`/admin/feeds/${feed.id}/emails`}
class="button button-small"
>
Emails
</a>
<button
type="button"
class="button button-small button-danger button-delete"
data-delete-kind="feed"
data-feed-id={feed.id}
data-view="table"
>
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</form>
</div>
) : (
<>
<div class="toolbar">
<div class="toolbar-group toolbar-group-fill">
<input
type="search"
id="feed-search"
class="search"
placeholder="Search title, feed id, or description"
oninput="scheduleFeedFilter()"
/>
<span class="pill">Tip: use Table view for bulk deletion.</span>
</div>
</div>
<ul class="feed-list">
{feedsWithConfig.map((feed) => {
const emailAddress = feedEmailAddress(feed.id, env);
const rssUrl = feedRssUrl(feed.id, env);
const atomUrl = feedAtomUrl(feed.id, env);
const titleDisplay = clampText(feed.title, 140);
const titleHover = clampText(feed.title, 1000);
const descDisplay = clampText(feed.description || "", 240);
const descHover = clampText(feed.description || "", 1000);
const searchHaystack =
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
return (
<li
class="feed-item card feed-row"
data-feed-id={feed.id}
data-search={searchHaystack}
>
<div class="feed-header">
<h3 class="feed-title" title={titleHover}>
{titleDisplay}
</h3>
{feed.description && (
<p class="feed-description">
<span title={descHover}>{descDisplay}</span>
</p>
)}
</div>
<div style="margin-bottom: var(--spacing-md);">
<div class="copyable">
<span class="copyable-label">Email:</span>
<div class="copyable-content">
<span
class="copyable-value"
data-copy={emailAddress}
title={emailAddress}
>
{emailAddress}
</span>
<div class="copy-icon-container">
<CopyIcon />
<CheckIcon />
</div>
</div>
</div>
<div class="copyable">
<span class="copyable-label">RSS Feed:</span>
<div class="copyable-content">
<span
class="copyable-value"
data-copy={rssUrl}
title={rssUrl}
>
{rssUrl}
</span>
<div class="copy-icon-container">
<CopyIcon />
<CheckIcon />
</div>
</div>
</div>
<div class="copyable">
<span class="copyable-label">Atom Feed:</span>
<div class="copyable-content">
<span
class="copyable-value"
data-copy={atomUrl}
title={atomUrl}
>
{atomUrl}
</span>
<div class="copy-icon-container">
<CopyIcon />
<CheckIcon />
</div>
</div>
</div>
</div>
<div class="feed-buttons">
<div class="feed-buttons-left">
<a
href={`/admin/feeds/${feed.id}/edit`}
class="button button-small"
>
Edit
</a>
<a
href={`/admin/feeds/${feed.id}/emails`}
class="button button-small"
>
Emails
</a>
</div>
<div class="feed-buttons-right">
<button
type="button"
class="button button-small button-danger button-delete"
data-delete-kind="feed"
data-feed-id={feed.id}
data-view="list"
>
Delete
</button>
</div>
</div>
</li>
);
})}
</ul>
</>
)}
</div>
<script dangerouslySetInnerHTML={{ __html: dashboardScript }} />
</Layout>,
);
});
// Mount sub-routers
app.route("/feeds", feedsRouter);
app.route("/", emailsRouter);
// Update feed via API (for in-place editing)
app.post(
"/api/feeds/:feedId/update",
zValidator(
"json",
updateFeedSchema.pick({ title: true, description: true }),
(result, c) => {
if (!result.success)
return c.json({ success: false, error: result.error.issues }, 400);
},
),
async (c) => {
// Type assertion for environment variables
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
try {
const { title, description } = c.req.valid("json");
const parsedData = { title, description, language: "en" as const };
// Get existing feed config
const feedConfigKey = `feed:${feedId}:config`;
const existingConfig = (await emailStorage.get(feedConfigKey, {
type: "json",
})) as FeedConfig | null;
if (!existingConfig) {
return c.json({ error: "Feed not found" }, 404);
}
// Update feed configuration
await emailStorage.put(
feedConfigKey,
JSON.stringify({
...existingConfig,
title: parsedData.title,
description: parsedData.description,
updated_at: Date.now(),
}),
);
// Update feed in the list of all feeds
await updateFeedInList(
emailStorage,
feedId,
parsedData.title,
parsedData.description,
);
// Return success response
return c.json({ success: true });
} catch (error) {
logger.error("Error updating feed via API", { error: String(error) });
return c.json({ error: "Error updating feed" }, 400);
}
},
);
// Export the Hono app
export const handle = app;
+757
View File
@@ -0,0 +1,757 @@
import { Hono } from "hono";
import {
Env,
FeedConfig,
FeedMetadata,
EmailData,
EmailMetadata,
} from "../../types";
import { logger } from "../../lib/logger";
import { Layout, clampText } from "./ui";
import { deleteKeysWithConcurrency } from "./helpers";
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../../utils/urls";
import { emailsPageScript } from "../../scripts/generated/emails-page";
type AppEnv = { Bindings: Env };
export const emailsRouter = new Hono<AppEnv>();
// ── Shared SVG icons ──────────────────────────────────────────────────────────
const CopyIcon = () => (
<svg
class="copy-icon copy-icon-original"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
);
const CheckIcon = () => (
<svg
class="copy-icon copy-icon-success"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6L9 17l-5-5"></path>
</svg>
);
type CopyFieldProps = {
label: string;
value: string;
display?: string;
};
const CopyField = ({ label, value, display }: CopyFieldProps) => (
<div class="copyable">
<span class="copyable-label">{label}</span>
<div class="copyable-content">
<span class="copyable-value" data-copy={value}>
{display ?? value}
</span>
<div class="copy-icon-container">
<CopyIcon />
<CheckIcon />
</div>
</div>
</div>
);
function extractSenderEmail(from: string): string {
const match = from.match(/<([^>]+@[^>]+)>/);
return match ? match[1].trim().toLowerCase() : from.trim().toLowerCase();
}
type SenderFieldProps = {
from: string;
feedId: string;
};
const SenderField = ({ from, feedId }: SenderFieldProps) => {
const senderEmail = extractSenderEmail(from);
const senderDomain = senderEmail.split("@")[1] || "";
return (
<div class="copyable">
<span class="copyable-label">From:</span>
<div class="copyable-content">
<span class="copyable-value" data-copy={from}>
{from}
</span>
<div class="copy-icon-container">
<CopyIcon />
<CheckIcon />
</div>
</div>
<div
class="sender-filter-dropdown"
data-feed-id={feedId}
data-email={senderEmail}
data-domain={senderDomain}
>
<button
type="button"
class="sender-filter-btn"
aria-label="Sender filter options"
onclick="toggleSenderFilter(this)"
>
⋮
</button>
<div class="sender-filter-menu" role="menu">
<button
type="button"
class="sender-filter-item sender-filter-allow"
data-action="allow_sender"
>
Allow {senderEmail}
</button>
<button
type="button"
class="sender-filter-item sender-filter-allow"
data-action="allow_domain"
>
Allow @{senderDomain}
</button>
<button
type="button"
class="sender-filter-item sender-filter-block"
data-action="block_sender"
>
Block {senderEmail}
</button>
<button
type="button"
class="sender-filter-item sender-filter-block"
data-action="block_domain"
>
Block @{senderDomain}
</button>
</div>
<span class="sender-filter-feedback" aria-live="polite"></span>
</div>
</div>
);
};
// ── View all emails for a feed ────────────────────────────────────────────────
emailsRouter.get("/feeds/:feedId/emails", async (c) => {
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
const message = c.req.query("message");
const count = Number(c.req.query("count") || "0");
const feedConfig = (await emailStorage.get(`feed:${feedId}:config`, {
type: "json",
})) as FeedConfig | null;
const feedMetadata = (await emailStorage.get(`feed:${feedId}:metadata`, {
type: "json",
})) as FeedMetadata | null;
if (!feedConfig || !feedMetadata) {
return c.text("Feed not found", 404);
}
const emailAddress = feedEmailAddress(feedId, env);
const rssUrl = feedRssUrl(feedId, env);
const atomUrl = feedAtomUrl(feedId, env);
return c.html(
<Layout title={`${feedConfig.title} - Emails`}>
<div class="container container-wide fade-in">
<div class="header-with-actions">
<div class="header-title">
<h1>{feedConfig.title} - Emails</h1>
</div>
<div class="header-actions">
<a href="/admin" class="button button-secondary button-back">
Back to Dashboard
</a>
</div>
</div>
<div class="card">
<h2>Feed Details</h2>
<div>
<CopyField label="Email Address:" value={emailAddress} />
<CopyField label="RSS Feed:" value={rssUrl} />
<CopyField label="Atom Feed:" value={atomUrl} />
</div>
<div class="feed-validate">
<a
href={`https://validator.w3.org/feed/check.cgi?url=${encodeURIComponent(atomUrl)}`}
target="_blank"
rel="noopener noreferrer"
>
<img
src="https://validator.w3.org/feed/images/valid-atom.png"
alt="[Valid Atom 1.0]"
title="Validate my Atom 1.0 feed"
/>
</a>
<a
href={`https://validator.w3.org/feed/check.cgi?url=${encodeURIComponent(rssUrl)}`}
target="_blank"
rel="noopener noreferrer"
>
<img
src="https://validator.w3.org/feed/images/valid-rss-rogers.png"
alt="[Valid RSS]"
title="Validate my RSS feed"
/>
</a>
</div>
</div>
<h2>
Emails (
<span id="email-total-count">{feedMetadata.emails.length}</span>)
</h2>
{message === "bulkDeleted" && (
<div class="card">
<p>Deleted {Number.isFinite(count) ? count : 0} email(s).</p>
</div>
)}
{message === "bulkDeleteNoop" && (
<div class="card">
<p>No emails were selected.</p>
</div>
)}
{feedMetadata.emails.length > 0 ? (
<form
action={`/admin/feeds/${feedId}/emails/bulk-delete`}
method="post"
onsubmit="return onBulkEmailDeleteSubmit(event)"
>
<div class="toolbar">
<div class="toolbar-group toolbar-group-fill">
<input
type="search"
id="email-search"
class="search"
placeholder="Search email subjects"
oninput="scheduleEmailFilter()"
/>
<span class="pill" id="email-match-count">
Showing {feedMetadata.emails.length}
</span>
<span class="pill" id="selected-email-count">
0 selected
</span>
<button
type="button"
class="button button-small button-secondary"
onclick="selectMatchingEmails()"
>
Select Results
</button>
<button
type="button"
class="button button-small button-secondary"
onclick="clearEmailSelection()"
>
Clear Selection
</button>
<button
id="bulk-delete-emails-button"
type="submit"
class="button button-small button-danger"
disabled
>
Delete Selected
</button>
</div>
</div>
<div class="table-wrap">
<table class="table table-emails">
<colgroup>
<col data-col="select" style="width: 44px;" />
<col data-col="subject" style="width: 520px;" />
<col data-col="receivedAt" style="width: 220px;" />
<col data-col="actions" style="width: 200px;" />
</colgroup>
<thead>
<tr>
<th>
<input
type="checkbox"
id="select-all-emails"
onchange="toggleAllEmails(this.checked)"
/>
</th>
<th
class="th-resizable"
data-sort-key="subject"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="subject"
>
Subject
<span class="sort-indicator" aria-hidden="true"></span>
</button>
<div
class="col-resizer"
data-col="subject"
title="Resize"
></div>
</th>
<th
class="th-resizable"
data-sort-key="receivedAt"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="receivedAt"
>
Received
<span class="sort-indicator" aria-hidden="true"></span>
</button>
<div
class="col-resizer"
data-col="receivedAt"
title="Resize"
></div>
</th>
<th class="th-resizable">
<span>Actions</span>
<div
class="col-resizer"
data-col="actions"
title="Resize"
></div>
</th>
</tr>
</thead>
<tbody>
{feedMetadata.emails.map((email: EmailMetadata) => {
const subjectDisplay = clampText(email.subject, 180);
const subjectHover = clampText(email.subject, 1000);
const sortSubject = subjectHover.toLowerCase();
const sortReceivedAt = String(email.receivedAt);
const searchHaystack = clampText(
email.subject,
320,
).toLowerCase();
return (
<tr
class="email-row"
data-email-key={email.key}
data-search={searchHaystack}
data-sort-subject={sortSubject}
data-sort-received-at={sortReceivedAt}
>
<td>
<input
type="checkbox"
class="email-select"
name="emailKeys"
value={email.key}
onchange="updateEmailSelectionState()"
/>
</td>
<td>
<span class="truncate" title={subjectHover}>
{subjectDisplay}
</span>
</td>
<td>{new Date(email.receivedAt).toLocaleString()}</td>
<td>
<div class="row-actions">
<a
href={`/admin/emails/${email.key}`}
class="button button-small"
>
View
</a>
<button
type="button"
class="button button-small button-danger button-delete"
data-delete-kind="email"
data-email-key={email.key}
data-feed-id={feedId}
>
Delete
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</form>
) : (
<div class="card">
<p>
No emails received yet. Subscribe to newsletters using the email
address above.
</p>
</div>
)}
</div>
{/* Config bootstrap — injects dynamic server-side data before the static compiled script */}
<script dangerouslySetInnerHTML={{ __html: `window.__APP_CONFIG__=${JSON.stringify({ feedId })}` }} />
{/* Emails page logic compiled from src/scripts/client/emails-page.ts */}
<script dangerouslySetInnerHTML={{ __html: emailsPageScript }} />
</Layout>,
);
});
// ── View single email ─────────────────────────────────────────────────────────
emailsRouter.get("/emails/:emailKey", async (c) => {
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const emailKey = c.req.param("emailKey");
const emailData = (await emailStorage.get(emailKey, {
type: "json",
})) as EmailData | null;
if (!emailData) return c.text("Email not found", 404);
const feedId = emailKey.split(":")[1];
const htmlContent = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text','SF Pro Display','Helvetica Neue',Arial,sans-serif;line-height:1.5;padding:16px;margin:0;color:#333;box-sizing:border-box}img{max-width:100%;height:auto}a{color:#0070f3}@media(prefers-color-scheme:dark){body{background-color:#1c1c1e;color:#ffffff}a{color:#0a84ff}}</style></head><body>${emailData.content}</body></html>`;
const encodedHtmlContent = (() => {
const encoder = new TextEncoder();
const bytes = encoder.encode(htmlContent);
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
})();
const rawHtml = emailData.content
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const viewScript = `
function showRendered() {
document.getElementById('rendered-view').style.display = 'block';
document.getElementById('raw-view').style.display = 'none';
document.getElementById('rendered-button').classList.add('active');
document.getElementById('raw-button').classList.remove('active');
}
function showRaw() {
document.getElementById('rendered-view').style.display = 'none';
document.getElementById('raw-view').style.display = 'block';
document.getElementById('rendered-button').classList.remove('active');
document.getElementById('raw-button').classList.add('active');
}
window.addEventListener('load', function() {
const iframe = document.querySelector('.email-iframe');
if (!iframe) return;
iframe.style.height = '500px';
try {
iframe.onload = function() {
const doc = iframe.contentDocument || iframe.contentWindow.document;
if (doc) iframe.style.height = Math.min(800, Math.max(500, doc.body.scrollHeight)) + 'px';
};
} catch (e) { /* cross-origin */ }
});
function toggleSenderFilter(btn) {
var dropdown = btn.closest('.sender-filter-dropdown');
var isOpen = dropdown.hasAttribute('data-open');
document.querySelectorAll('.sender-filter-dropdown[data-open]').forEach(function(d) {
d.removeAttribute('data-open');
});
if (!isOpen) dropdown.setAttribute('data-open', '');
}
function showSenderFeedback(feedback, ok, msg) {
feedback.textContent = msg;
feedback.className = 'sender-filter-feedback ' + (ok ? 'sender-filter-feedback-ok' : 'sender-filter-feedback-error');
setTimeout(function() {
feedback.textContent = '';
feedback.className = 'sender-filter-feedback';
}, 3000);
}
document.addEventListener('click', function(e) {
var item = e.target.closest('.sender-filter-item');
if (item) {
var dropdown = item.closest('.sender-filter-dropdown');
var action = item.dataset.action;
var value = (action === 'allow_sender' || action === 'block_sender')
? dropdown.dataset.email
: dropdown.dataset.domain;
dropdown.removeAttribute('data-open');
var feedback = dropdown.querySelector('.sender-filter-feedback');
fetch('/admin/feeds/' + dropdown.dataset.feedId + '/sender-filter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action, value: value })
}).then(function(res) {
return res.json();
}).then(function(data) {
showSenderFeedback(feedback, data.ok, data.ok ? 'Saved' : (data.error || 'Error'));
}).catch(function() {
showSenderFeedback(feedback, false, 'Network error');
});
return;
}
if (!e.target.closest('.sender-filter-dropdown')) {
document.querySelectorAll('.sender-filter-dropdown[data-open]').forEach(function(d) {
d.removeAttribute('data-open');
});
}
});
`;
return c.html(
<Layout title={`Email: ${emailData.subject}`}>
<div class="container fade-in">
<div class="header-with-actions">
<div class="header-title">
<h1>Email Content</h1>
</div>
<div class="header-actions">
<a
href={`/admin/feeds/${feedId}/emails`}
class="button button-secondary button-back"
>
Back to Emails
</a>
</div>
</div>
<div class="card">
<div class="email-meta">
<div class="email-metadata-grid">
<CopyField label="Subject:" value={emailData.subject} />
<CopyField
label="Received:"
value={new Date(emailData.receivedAt).toLocaleString()}
/>
<SenderField from={emailData.from} feedId={feedId} />
<CopyField
label="To:"
value={feedEmailAddress(feedId, env)}
/>
</div>
</div>
<div class="toggle-view">
<button
id="rendered-button"
class="toggle-button active"
onclick="showRendered()"
>
Rendered View
</button>
<button id="raw-button" class="toggle-button" onclick="showRaw()">
Raw HTML
</button>
</div>
<div class="email-content">
<div id="rendered-view" class="email-iframe-container">
<iframe
class="email-iframe"
src={`data:text/html;base64,${encodedHtmlContent}`}
></iframe>
</div>
<div id="raw-view" class="email-raw" style="display: none;">
<pre dangerouslySetInnerHTML={{ __html: rawHtml }}></pre>
</div>
</div>
</div>
</div>
<script dangerouslySetInnerHTML={{ __html: viewScript }} />
</Layout>,
);
});
// ── Delete single email ───────────────────────────────────────────────────────
emailsRouter.post("/emails/:emailKey/delete", async (c) => {
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const emailKey = c.req.param("emailKey");
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
try {
const feedId = c.req.query("feedId");
if (!feedId) {
if (wantsJson)
return c.json({ ok: false, error: "Feed ID is required" }, 400);
return c.text("Feed ID is required", 400);
}
const feedMetadataKey = `feed:${feedId}:metadata`;
const feedMetadata = (await emailStorage.get(feedMetadataKey, {
type: "json",
})) as FeedMetadata | null;
const attachmentIds =
feedMetadata?.emails.find((e) => e.key === emailKey)?.attachmentIds ?? [];
await emailStorage.delete(emailKey);
if (feedMetadata) {
feedMetadata.emails = feedMetadata.emails.filter(
(email) => email.key !== emailKey,
);
await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata));
}
if (env.ATTACHMENT_BUCKET && attachmentIds.length > 0) {
await Promise.allSettled(
attachmentIds.map((id) => env.ATTACHMENT_BUCKET!.delete(id)),
);
}
if (wantsJson) return c.json({ ok: true, emailKey, feedId });
return c.redirect(`/admin/feeds/${feedId}/emails`);
} catch (error) {
logger.error("Error deleting email", { emailKey, error: String(error) });
if (wantsJson)
return c.json(
{ ok: false, error: "Error deleting email. Please try again." },
400,
);
return c.text("Error deleting email. Please try again.", 400);
}
});
// ── Bulk delete emails ────────────────────────────────────────────────────────
emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
const contentType = c.req.header("Content-Type") || "";
const wantsJson =
contentType.includes("application/json") ||
(c.req.header("Accept") || "").includes("application/json");
try {
const feedMetadataKey = `feed:${feedId}:metadata`;
const feedMetadata = (await emailStorage.get(feedMetadataKey, {
type: "json",
})) as FeedMetadata | null;
if (!feedMetadata) {
return wantsJson
? c.json({ ok: false, error: "Feed not found" }, 404)
: c.text("Feed not found", 404);
}
const allowedKeys = new Set(feedMetadata.emails.map((email) => email.key));
if (wantsJson) {
const body = (await c.req.json().catch(() => null)) as {
emailKeys?: unknown;
} | null;
const rawEmailKeys = Array.isArray(body?.emailKeys)
? body?.emailKeys
: [];
const emailKeys = Array.from(
new Set(rawEmailKeys.map((value) => String(value)).filter(Boolean)),
);
if (emailKeys.length === 0)
return c.json({ ok: false, error: "No emails were selected." }, 400);
if (emailKeys.length > 250) {
return c.json(
{
ok: false,
error:
"Too many emailKeys for a single request. Please delete in smaller batches.",
},
413,
);
}
const candidates = emailKeys.filter((key) => allowedKeys.has(key));
const candidateSet = new Set(candidates);
const r2AttachmentIds = feedMetadata.emails
.filter((e) => candidateSet.has(e.key))
.flatMap((e) => e.attachmentIds ?? []);
const { ok: deletedOk, failed: failedEmailKeys } =
await deleteKeysWithConcurrency(emailStorage, candidates, 35);
const deletedSet = new Set(deletedOk);
feedMetadata.emails = feedMetadata.emails.filter(
(email) => !deletedSet.has(email.key),
);
await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata));
if (env.ATTACHMENT_BUCKET && r2AttachmentIds.length > 0) {
await Promise.allSettled(
r2AttachmentIds.map((id) => env.ATTACHMENT_BUCKET!.delete(id)),
);
}
return c.json({
ok: failedEmailKeys.length === 0,
deletedEmailKeys: deletedOk,
failedEmailKeys,
});
}
const formData = await c.req.formData();
const rawEmailKeys = formData
.getAll("emailKeys")
.map((value) => value.toString());
const emailKeys = Array.from(new Set(rawEmailKeys.filter(Boolean)));
if (emailKeys.length === 0)
return c.redirect(`/admin/feeds/${feedId}/emails?message=bulkDeleteNoop`);
const candidates = emailKeys.filter((key) => allowedKeys.has(key));
const { ok: deletedOk } = await deleteKeysWithConcurrency(
emailStorage,
candidates,
35,
);
const deletedSet = new Set(deletedOk);
feedMetadata.emails = feedMetadata.emails.filter(
(email) => !deletedSet.has(email.key),
);
await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata));
return c.redirect(
`/admin/feeds/${feedId}/emails?message=bulkDeleted&count=${deletedOk.length}`,
);
} catch (error) {
logger.error("Error bulk deleting emails", {
feedId,
error: String(error),
});
return wantsJson
? c.json(
{
ok: false,
error:
"Server error while deleting emails. This can happen if Cloudflare is rate-limiting requests or if the Worker hit a plan quota. Please try again.",
},
500,
)
: c.text("Error bulk deleting emails. Please try again.", 500);
}
});
+671
View File
@@ -0,0 +1,671 @@
import { Hono } from "hono";
import { z } from "zod";
import { Env, FeedConfig, FeedMetadata, EmailData } from "../../types";
import { generateFeedId } from "../../utils/id-generator";
import { waitUntilSafe } from "../../utils/worker";
import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
import { logger } from "../../lib/logger";
import { Layout } from "./ui";
import {
addFeedToList,
updateFeedInList,
removeFeedFromList,
removeFeedsFromListBulk,
deleteKeysWithConcurrency,
} from "./helpers";
type AppEnv = { Bindings: Env };
export const feedsRouter = new Hono<AppEnv>();
function normalizeAllowedSenders(senders: string[]): string[] {
return senders.map((s) => s.trim().toLowerCase()).filter(Boolean);
}
function parseAllowedSenders(rawAllowedSenders: string): string[] {
return normalizeAllowedSenders(rawAllowedSenders.split(/[\n,]+/));
}
const createFeedSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
language: z.string().optional().default("en"),
allowedSenders: z.array(z.string()).optional().default([]),
blockedSenders: z.array(z.string()).optional().default([]),
});
const updateFeedSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
language: z.string().optional().default("en"),
allowedSenders: z.array(z.string()).optional().default([]),
blockedSenders: z.array(z.string()).optional().default([]),
});
const senderFilterSchema = z.object({
action: z.enum(["allow_sender", "allow_domain", "block_sender", "block_domain"]),
value: z.string().min(1),
});
// ── Delete helpers ────────────────────────────────────────────────────────────
type DeleteFeedFastResult = {
ok: boolean;
configDeleted: boolean;
metadataDeleted: boolean;
errors: string[];
};
async function deleteFeedFastDetailed(
emailStorage: KVNamespace,
feedId: string,
): Promise<DeleteFeedFastResult> {
const feedConfigKey = `feed:${feedId}:config`;
const feedMetadataKey = `feed:${feedId}:metadata`;
const errors: string[] = [];
let configDeleted = false;
let metadataDeleted = false;
try {
await emailStorage.delete(feedConfigKey);
configDeleted = true;
} catch (error) {
errors.push(`config delete failed: ${String(error)}`);
}
try {
await emailStorage.delete(feedMetadataKey);
metadataDeleted = true;
} catch (error) {
errors.push(`metadata delete failed: ${String(error)}`);
}
return { ok: configDeleted, configDeleted, metadataDeleted, errors };
}
async function deleteFeedFast(
emailStorage: KVNamespace,
feedId: string,
): Promise<boolean> {
const result = await deleteFeedFastDetailed(emailStorage, feedId);
return result.ok;
}
async function purgeFeedKeysStep(
emailStorage: KVNamespace,
feedId: string,
options: { cursor?: string; limit?: number; bucket?: R2Bucket } = {},
): Promise<{
deletedKeys: string[];
failedKeys: string[];
cursor: string;
listComplete: boolean;
}> {
const prefix = `feed:${feedId}:`;
const limit = Math.min(1000, Math.max(1, Math.floor(options.limit || 100)));
const cursor = options.cursor || undefined;
const listed = await emailStorage.list({ prefix, cursor, limit });
const keys = (listed.keys || []).map((k) => k.name);
if (options.bucket && keys.length > 0) {
const emailKeys = keys.filter((k) => {
const suffix = k.slice(prefix.length);
return suffix !== "config" && suffix !== "metadata";
});
if (emailKeys.length > 0) {
const emailDataResults = await Promise.allSettled(
emailKeys.map(
(k) =>
emailStorage.get(k, { type: "json" }) as Promise<EmailData | null>,
),
);
const attachmentIds = emailDataResults
.filter(
(r): r is PromiseFulfilledResult<EmailData | null> =>
r.status === "fulfilled",
)
.flatMap((r) => r.value?.attachments?.map((a) => a.id) ?? []);
if (attachmentIds.length > 0) {
await Promise.allSettled(
attachmentIds.map((id) => options.bucket!.delete(id)),
);
}
}
}
const { ok, failed } = await deleteKeysWithConcurrency(
emailStorage,
keys,
35,
);
return {
deletedKeys: ok,
failedKeys: failed,
cursor: listed.cursor || "",
listComplete: !!listed.list_complete,
};
}
// ── Routes ────────────────────────────────────────────────────────────────────
feedsRouter.post("/create", async (c) => {
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const isJson =
c.req.header("Content-Type")?.includes("application/json") ?? false;
try {
let title: string;
let description: string | undefined;
let language: string;
let view: string;
let allowedSenders: string[];
let blockedSenders: string[];
if (isJson) {
const body = await c.req.json<Record<string, unknown>>();
title = String(body.title ?? "");
description =
body.description != null ? String(body.description) : undefined;
language = String(body.language ?? "en");
view = "list";
allowedSenders = Array.isArray(body.allowedSenders)
? normalizeAllowedSenders(
(body.allowedSenders as unknown[]).map(String),
)
: [];
blockedSenders = Array.isArray(body.blockedSenders)
? normalizeAllowedSenders(
(body.blockedSenders as unknown[]).map(String),
)
: [];
} else {
const formData = await c.req.formData();
title = formData.get("title")?.toString() || "";
description = formData.get("description")?.toString();
language = formData.get("language")?.toString() || "en";
view = formData.get("view")?.toString() === "table" ? "table" : "list";
allowedSenders = parseAllowedSenders(
formData.get("allowed_senders")?.toString() || "",
);
blockedSenders = parseAllowedSenders(
formData.get("blocked_senders")?.toString() || "",
);
}
const parsedData = createFeedSchema.parse({
title,
description,
language,
allowedSenders,
blockedSenders,
});
const feedId = generateFeedId();
const feedConfig: FeedConfig = {
title: parsedData.title,
description: parsedData.description,
language: parsedData.language,
allowed_senders: parsedData.allowedSenders,
blocked_senders: parsedData.blockedSenders,
created_at: Date.now(),
updated_at: Date.now(),
};
const feedMetadata: FeedMetadata = { emails: [] };
await Promise.all([
emailStorage.put(`feed:${feedId}:config`, JSON.stringify(feedConfig)),
emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(feedMetadata)),
]);
await addFeedToList(
emailStorage,
feedId,
parsedData.title,
parsedData.description,
);
if (isJson) {
return c.json({
feedId,
email: feedEmailAddress(feedId, env),
feedUrl: feedRssUrl(feedId, env),
});
}
return c.redirect(`/admin?view=${view}`);
} catch (error) {
logger.error("Error creating feed", { error: String(error) });
if (c.req.header("Content-Type")?.includes("application/json")) {
return c.json({ error: "Error creating feed." }, 400);
}
return c.text("Error creating feed. Please try again.", 400);
}
});
feedsRouter.get("/:feedId/edit", async (c) => {
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
const feedConfig = (await emailStorage.get(`feed:${feedId}:config`, {
type: "json",
})) as FeedConfig | null;
if (!feedConfig) {
return c.text("Feed not found", 404);
}
return c.html(
<Layout title="Edit Feed">
<div class="container fade-in">
<div class="header-with-actions">
<div class="header-title">
<h1>{feedConfig.title} - Edit Feed</h1>
</div>
<div class="header-actions">
<a href="/admin" class="button button-secondary button-back">
Back to Dashboard
</a>
</div>
</div>
<div class="card">
<form action={`/admin/feeds/${feedId}/edit`} method="post">
<div class="form-group">
<label for="title">Feed Title</label>
<input
type="text"
id="title"
name="title"
value={feedConfig.title}
required
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows={3}>
{feedConfig.description || ""}
</textarea>
</div>
<div class="form-group">
<label for="allowed_senders">
Allowed senders (optional, one email or domain per line)
</label>
<textarea
id="allowed_senders"
name="allowed_senders"
rows={3}
placeholder={"[email protected]\ntechmeme.com"}
>
{(feedConfig.allowed_senders || []).join("\n")}
</textarea>
<small>
When set, inbound emails are only accepted from these
senders/domains.
</small>
</div>
<div class="form-group">
<label for="blocked_senders">
Blocked senders (optional, one email or domain per line)
</label>
<textarea
id="blocked_senders"
name="blocked_senders"
rows={3}
placeholder={"[email protected]\nunwanted.com"}
>
{(feedConfig.blocked_senders || []).join("\n")}
</textarea>
<small>
Emails from these senders/domains are always rejected, even if
they match the allowlist.
</small>
</div>
<input type="hidden" id="language" name="language" value="en" />
<button type="submit" class="button">
Update Feed
</button>
</form>
</div>
</div>
</Layout>,
);
});
feedsRouter.post("/:feedId/edit", async (c) => {
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
try {
const formData = await c.req.formData();
const title = formData.get("title")?.toString() || "";
const description = formData.get("description")?.toString();
const language = formData.get("language")?.toString() || "en";
const allowedSenders = parseAllowedSenders(
formData.get("allowed_senders")?.toString() || "",
);
const blockedSenders = parseAllowedSenders(
formData.get("blocked_senders")?.toString() || "",
);
const parsedData = updateFeedSchema.parse({
title,
description,
language,
allowedSenders,
blockedSenders,
});
const feedConfigKey = `feed:${feedId}:config`;
const existingConfig = (await emailStorage.get(feedConfigKey, {
type: "json",
})) as FeedConfig | null;
if (!existingConfig) {
return c.text("Feed not found", 404);
}
await emailStorage.put(
feedConfigKey,
JSON.stringify({
...existingConfig,
title: parsedData.title,
description: parsedData.description,
language: parsedData.language,
allowed_senders: parsedData.allowedSenders,
blocked_senders: parsedData.blockedSenders,
updated_at: Date.now(),
}),
);
await updateFeedInList(
emailStorage,
feedId,
parsedData.title,
parsedData.description,
);
return c.redirect("/admin");
} catch (error) {
logger.error("Error updating feed", { feedId, error: String(error) });
return c.text("Error updating feed. Please try again.", 400);
}
});
// ── Sender filter quick-add ───────────────────────────────────────────────────
feedsRouter.post("/:feedId/sender-filter", async (c) => {
const env = c.env;
const feedId = c.req.param("feedId");
const feedConfigKey = `feed:${feedId}:config`;
const body = await c.req.json().catch(() => null);
const parsed = senderFilterSchema.safeParse(body);
if (!parsed.success) {
return c.json({ ok: false, error: "Invalid request" }, 400);
}
const { action, value } = parsed.data;
const normalized = value.trim().toLowerCase();
const feedConfig = (await env.EMAIL_STORAGE.get(feedConfigKey, {
type: "json",
})) as FeedConfig | null;
if (!feedConfig) return c.json({ ok: false, error: "Feed not found" }, 404);
const allowedSenders = (feedConfig.allowed_senders || []).map((s) =>
s.trim().toLowerCase(),
);
const blockedSenders = (feedConfig.blocked_senders || []).map((s) =>
s.trim().toLowerCase(),
);
const isAllowAction = action === "allow_sender" || action === "allow_domain";
const targetList = isAllowAction ? allowedSenders : blockedSenders;
const oppositeList = isAllowAction ? blockedSenders : allowedSenders;
const oppositeLabel = isAllowAction ? "blocklist" : "allowlist";
if (oppositeList.includes(normalized)) {
return c.json(
{
ok: false,
error: `"${normalized}" is already in the ${oppositeLabel}`,
},
409,
);
}
if (!targetList.includes(normalized)) {
targetList.push(normalized);
await env.EMAIL_STORAGE.put(
feedConfigKey,
JSON.stringify({
...feedConfig,
allowed_senders: allowedSenders,
blocked_senders: blockedSenders,
updated_at: Date.now(),
}),
);
}
return c.json({ ok: true });
});
feedsRouter.post("/:feedId/delete", async (c) => {
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
const view = c.req.query("view") === "table" ? "table" : "list";
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
try {
await deleteFeedFast(emailStorage, feedId);
await removeFeedFromList(emailStorage, feedId);
waitUntilSafe(
c,
purgeFeedKeysStep(emailStorage, feedId, {
bucket: env.ATTACHMENT_BUCKET,
}),
);
if (wantsJson) {
return c.json({ ok: true, feedId });
}
return c.redirect(`/admin?view=${view}`);
} catch (error) {
logger.error("Error deleting feed", { feedId, error: String(error) });
if (wantsJson) {
return c.json(
{ ok: false, error: "Error deleting feed. Please try again." },
400,
);
}
return c.text("Error deleting feed. Please try again.", 400);
}
});
feedsRouter.post("/:feedId/purge", async (c) => {
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const feedId = c.req.param("feedId");
try {
const body = (await c.req.json().catch(() => null)) as {
cursor?: unknown;
limit?: unknown;
} | null;
const cursor = body?.cursor ? String(body.cursor) : undefined;
const limit = Number.isFinite(Number(body?.limit))
? Number(body?.limit)
: 100;
const step = await purgeFeedKeysStep(emailStorage, feedId, {
cursor,
limit,
bucket: env.ATTACHMENT_BUCKET,
});
return c.json({
ok: step.failedKeys.length === 0,
deletedCount: step.deletedKeys.length,
failedCount: step.failedKeys.length,
cursor: step.cursor,
listComplete: step.listComplete,
});
} catch (error) {
logger.error("Error purging feed keys", { feedId, error: String(error) });
return c.json({ ok: false, error: "Error purging feed keys" }, 500);
}
});
feedsRouter.post("/bulk-delete", async (c) => {
const env = c.env;
const emailStorage = env.EMAIL_STORAGE;
const contentType = c.req.header("Content-Type") || "";
const wantsJson =
contentType.includes("application/json") ||
(c.req.header("Accept") || "").includes("application/json");
try {
if (wantsJson) {
const body = (await c.req.json().catch(() => null)) as {
feedIds?: unknown;
} | null;
const rawIds = Array.isArray(body?.feedIds) ? body?.feedIds : [];
const parsedFeedIds = Array.from(
new Set(rawIds.map((value) => String(value)).filter(Boolean)),
);
if (parsedFeedIds.length === 0) {
return c.json({ ok: false, error: "No feeds were selected." }, 400);
}
if (parsedFeedIds.length > 50) {
return c.json(
{
ok: false,
error:
"Too many feedIds for a single request. Please delete in smaller batches.",
},
413,
);
}
const okIds: string[] = [];
const failures: Array<{ feedId: string; error: string }> = [];
const warnings: Array<{ feedId: string; warning: string }> = [];
for (const feedId of parsedFeedIds) {
try {
const result = await deleteFeedFastDetailed(emailStorage, feedId);
if (!result.ok) {
failures.push({
feedId,
error:
result.errors.join("; ") ||
"Failed to delete feed config (feed may still be active).",
});
continue;
}
if (!result.metadataDeleted) {
warnings.push({
feedId,
warning:
"Feed config deleted, but metadata cleanup failed. This is usually safe, but storage cleanup may be incomplete.",
});
}
okIds.push(feedId);
} catch (error) {
logger.error("Error bulk deleting feed", {
feedId,
error: String(error),
});
failures.push({ feedId, error: String(error) });
}
}
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
const removed = new Set(deletedFeedIds);
okIds.forEach((feedId) => {
if (!removed.has(feedId)) {
failures.push({
feedId,
error:
"Feed config deleted, but failed to remove it from feeds:list. Refresh and try again.",
});
}
});
const failedFeedIds = Array.from(new Set(failures.map((f) => f.feedId)));
return c.json({
ok: failedFeedIds.length === 0,
deletedFeedIds,
failedFeedIds,
failures,
warnings,
});
}
const formData = await c.req.formData();
const view =
formData.get("view")?.toString() === "table" ? "table" : "list";
const redirectBase = `/admin?view=${view}`;
const rawIds = formData.getAll("feedIds").map((value) => value.toString());
const parsedFeedIds = Array.from(new Set(rawIds.filter(Boolean)));
if (parsedFeedIds.length === 0) {
return c.redirect(`${redirectBase}&message=bulkDeleteNoop`);
}
const okIds: string[] = [];
for (const feedId of parsedFeedIds) {
try {
const result = await deleteFeedFastDetailed(emailStorage, feedId);
if (result.ok) okIds.push(feedId);
} catch (error) {
logger.error("Error bulk deleting feed", {
feedId,
error: String(error),
});
}
}
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
return c.redirect(
`${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`,
);
} catch (error) {
logger.error("Error bulk deleting feeds", { error: String(error) });
return wantsJson
? c.json(
{
ok: false,
error:
"Server error while deleting feeds. This can happen if Cloudflare is rate-limiting requests or if the Worker hit a plan quota. Please try again.",
},
500,
)
: c.text("Error bulk deleting feeds. Please try again.", 500);
}
});
+130
View File
@@ -0,0 +1,130 @@
import { FeedList, FeedListItem } from "../../types";
import { FEEDS_LIST_KEY } from "../../config/constants";
import { logger } from "../../lib/logger";
export async function deleteKeysWithConcurrency(
emailStorage: KVNamespace,
keys: string[],
concurrency: number,
): Promise<{ ok: string[]; failed: string[] }> {
const uniqueKeys = Array.from(new Set(keys.filter(Boolean)));
const ok: string[] = [];
const failed: string[] = [];
const limit = Math.max(1, Math.floor(concurrency) || 1);
for (let i = 0; i < uniqueKeys.length; i += limit) {
const batch = uniqueKeys.slice(i, i + limit);
const results = await Promise.allSettled(
batch.map((key) => emailStorage.delete(key)),
);
results.forEach((result, idx) => {
const key = batch[idx];
if (result.status === "fulfilled") {
ok.push(key);
} else {
failed.push(key);
}
});
}
return { ok, failed };
}
export async function listAllFeeds(
emailStorage: KVNamespace,
): Promise<FeedListItem[]> {
try {
const feedList = (await emailStorage.get(FEEDS_LIST_KEY, {
type: "json",
})) as FeedList | null;
return feedList?.feeds || [];
} catch (error) {
logger.error("Error listing feeds", { error: String(error) });
return [];
}
}
export async function addFeedToList(
emailStorage: KVNamespace,
feedId: string,
title: string,
description?: string,
): Promise<void> {
try {
const feedList = ((await emailStorage.get(FEEDS_LIST_KEY, {
type: "json",
})) as FeedList | null) || { feeds: [] };
feedList.feeds.push({ id: feedId, title, description });
await emailStorage.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
} catch (error) {
logger.error("Error adding feed to list", { feedId, error: String(error) });
}
}
export async function updateFeedInList(
emailStorage: KVNamespace,
feedId: string,
title: string,
description?: string,
): Promise<void> {
try {
const feedList = ((await emailStorage.get(FEEDS_LIST_KEY, {
type: "json",
})) as FeedList | null) || { feeds: [] };
const feedIndex = feedList.feeds.findIndex((feed) => feed.id === feedId);
if (feedIndex !== -1) {
feedList.feeds[feedIndex].title = title;
feedList.feeds[feedIndex].description = description;
await emailStorage.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
}
} catch (error) {
logger.error("Error updating feed in list", {
feedId,
error: String(error),
});
}
}
export async function removeFeedsFromListBulk(
emailStorage: KVNamespace,
feedIds: string[],
): Promise<string[]> {
try {
const feedList = ((await emailStorage.get(FEEDS_LIST_KEY, {
type: "json",
})) as FeedList | null) || { feeds: [] };
const toRemove = new Set(feedIds.filter(Boolean));
if (toRemove.size === 0) return [];
const removed: string[] = [];
const nextFeeds: FeedListItem[] = [];
for (const feed of feedList.feeds) {
if (toRemove.has(feed.id)) {
removed.push(feed.id);
continue;
}
nextFeeds.push(feed);
}
if (removed.length === 0) return [];
feedList.feeds = nextFeeds;
await emailStorage.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
return removed;
} catch (error) {
logger.error("Error removing feeds from list", { error: String(error) });
return [];
}
}
export async function removeFeedFromList(
emailStorage: KVNamespace,
feedId: string,
): Promise<boolean> {
const removed = await removeFeedsFromListBulk(emailStorage, [feedId]);
return removed.includes(feedId);
}
+67
View File
@@ -0,0 +1,67 @@
import variablesCss from "../../styles/variables.css";
import layoutCss from "../../styles/layout.css";
import componentsCss from "../../styles/components.css";
import utilitiesCss from "../../styles/utilities.css";
import { interactiveScripts } from "../../scripts/index";
const designSystem = [variablesCss, layoutCss, componentsCss, utilitiesCss].join("\n");
type LayoutProps = {
title: string;
children: import("hono/jsx").Child;
};
export const Layout = ({ title, children }: LayoutProps) => {
return (
<html>
<head>
<title>{title} — kill-the-news</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin="anonymous"
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
{/* designSystem and interactiveScripts are static trusted strings, not user input */}
<style dangerouslySetInnerHTML={{ __html: designSystem }} />
<script dangerouslySetInnerHTML={{ __html: interactiveScripts + ";" }} />
</head>
<body class="page">
<header class="site-header">
<a href="https://kill-the.news/" class="site-header-logo" target="_blank" rel="noopener">
kill-the-news
</a>
<span class="site-header-label">admin</span>
</header>
{children}
<footer class="site-footer">
<a href="https://kill-the.news/" target="_blank" rel="noopener">kill-the.news</a>
<span class="site-footer-sep" aria-hidden="true">·</span>
<a href="https://github.com/sponsors/juherr" target="_blank" rel="noopener" class="site-footer-sponsor">
♥ Sponsor
</a>
</footer>
</body>
</html>
);
};
export { Layout as layout };
export function clampText(value: string, maxLen: number): string {
const raw = `${value || ""}`;
if (raw.length <= maxLen) {
return raw.trim();
}
if (maxLen <= 3) {
return raw.slice(0, maxLen).trim();
}
return `${raw.slice(0, maxLen - 3).trimEnd()}...`;
}
+142
View File
@@ -0,0 +1,142 @@
import { describe, it, expect, beforeEach } from "vitest";
import { Hono } from "hono";
import { handle } from "./atom";
import { createMockEnv } from "../test/setup";
import { Env } from "../types";
describe("Atom Feed Route", () => {
let testApp: Hono;
let mockEnv: Env;
beforeEach(() => {
mockEnv = createMockEnv() as unknown as Env;
testApp = new Hono();
testApp.get("/:feedId", handle);
});
describe("unknown feed", () => {
it("returns 404 when no metadata exists in KV", async () => {
const res = await testApp.request("/nonexistent-feed", {}, mockEnv);
expect(res.status).toBe(404);
expect(await res.text()).toBe("Feed not found");
});
});
describe("valid feed with no emails", () => {
beforeEach(async () => {
await mockEnv.EMAIL_STORAGE.put(
"feed:empty-feed:metadata",
JSON.stringify({ emails: [] }),
);
});
it("returns 200 with application/atom+xml content type", async () => {
const res = await testApp.request("/empty-feed", {}, mockEnv);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("application/atom+xml");
});
it("returns valid Atom XML with no <entry> elements", async () => {
const res = await testApp.request("/empty-feed", {}, mockEnv);
const body = await res.text();
expect(body).toContain('xmlns="http://www.w3.org/2005/Atom"');
expect(body).not.toContain("<entry>");
});
it("includes Cache-Control header", async () => {
const res = await testApp.request("/empty-feed", {}, mockEnv);
expect(res.headers.get("Cache-Control")).toBe("max-age=1800");
});
});
describe("valid feed with emails", () => {
const FEED_ID = "test-feed-atom";
beforeEach(async () => {
const emailKey = `feed:${FEED_ID}:1700000001000`;
await mockEnv.EMAIL_STORAGE.put(
emailKey,
JSON.stringify({
subject: "Atom Entry Subject",
from: "Sender <[email protected]>",
content: "<p>Email body</p>",
receivedAt: 1700000001000,
headers: {},
}),
);
await mockEnv.EMAIL_STORAGE.put(
`feed:${FEED_ID}:metadata`,
JSON.stringify({
emails: [
{
key: emailKey,
subject: "Atom Entry Subject",
receivedAt: 1700000001000,
},
],
}),
);
await mockEnv.EMAIL_STORAGE.put(
`feed:${FEED_ID}:config`,
JSON.stringify({
title: "Atom Test Feed",
description: "Integration test",
language: "en",
created_at: 1700000000000,
}),
);
});
it("returns 200 with application/atom+xml", async () => {
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("application/atom+xml");
});
it("contains Atom namespace declaration", async () => {
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
const body = await res.text();
expect(body).toContain('xmlns="http://www.w3.org/2005/Atom"');
});
it("contains <entry> for the email", async () => {
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
const body = await res.text();
expect(body).toContain("<entry>");
expect(body).toContain("Atom Entry Subject");
});
it("contains feed title", async () => {
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
const body = await res.text();
expect(body).toContain("Atom Test Feed");
});
it("self-link points to atom URL", async () => {
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
const body = await res.text();
expect(body).toContain(`/atom/${FEED_ID}`);
});
it("Link header advertises hub and self for WebSub discovery", async () => {
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
const link = res.headers.get("Link") ?? "";
expect(link).toContain(`rel="hub"`);
expect(link).toContain(`/atom/${FEED_ID}`);
expect(link).toContain(`rel="self"`);
});
});
describe("fallback config when no config in KV", () => {
it("uses atom path in fallback and returns 200", async () => {
await mockEnv.EMAIL_STORAGE.put(
"feed:no-config-feed:metadata",
JSON.stringify({ emails: [] }),
);
const res = await testApp.request("/no-config-feed", {}, mockEnv);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain('xmlns="http://www.w3.org/2005/Atom"');
});
});
});
+45
View File
@@ -0,0 +1,45 @@
import { Context } from "hono";
import { Env } from "../types";
import { generateAtomFeed } from "../utils/feed-generator";
import { fetchFeedData } from "../utils/feed-fetcher";
import { baseUrl, feedAtomUrl } from "../utils/urls";
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
try {
const feedId = c.req.param("feedId");
if (!feedId) {
return new Response("Feed ID is required", { status: 400 });
}
const feedData = await fetchFeedData(feedId, c.env);
if (!feedData) {
return new Response("Feed not found", { status: 404 });
}
const base = baseUrl(c.env);
const selfUrl = new URL(c.req.url).origin + `/atom/${feedId}`;
const atomXml = generateAtomFeed(
feedData.feedConfig,
feedData.emails,
base,
feedId,
selfUrl,
);
const linkHeader = [
`<${base}/hub>; rel="hub"`,
`<${feedAtomUrl(feedId, c.env)}>; rel="self"`,
].join(", ");
return new Response(atomXml, {
status: 200,
headers: {
"Content-Type": "application/atom+xml",
"Cache-Control": "max-age=1800",
Link: linkHeader,
},
});
} catch (error) {
console.error("Error generating Atom feed:", error);
return new Response("Internal Server Error", { status: 500 });
}
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect, beforeEach } from "vitest";
import { Hono } from "hono";
import { handle } from "./entries";
import { createMockEnv } from "../test/setup";
const FEED_ID = "test-feed";
const RECEIVED_AT = 1700000001000;
const EMAIL_KEY = `feed:${FEED_ID}:${RECEIVED_AT}`;
function makeApp() {
const app = new Hono();
app.get("/:feedId/:entryId", handle);
return app;
}
async function seedFeed(env: ReturnType<typeof createMockEnv>) {
await env.EMAIL_STORAGE.put(
EMAIL_KEY,
JSON.stringify({
subject: "Test Subject",
from: "[email protected]",
content: "<p>Email body</p>",
receivedAt: RECEIVED_AT,
headers: {},
}),
);
await env.EMAIL_STORAGE.put(
`feed:${FEED_ID}:metadata`,
JSON.stringify({
emails: [
{ key: EMAIL_KEY, subject: "Test Subject", receivedAt: RECEIVED_AT },
],
}),
);
}
describe("GET /entries/:feedId/:entryId", () => {
let env: ReturnType<typeof createMockEnv>;
beforeEach(() => {
env = createMockEnv();
});
it("returns 404 when feed does not exist", async () => {
const app = makeApp();
const res = await app.request(`/${FEED_ID}/999`, {}, env as any);
expect(res.status).toBe(404);
expect(await res.text()).toContain("Feed not found");
});
it("returns 404 when entry does not exist in metadata", async () => {
const app = makeApp();
await env.EMAIL_STORAGE.put(
`feed:${FEED_ID}:metadata`,
JSON.stringify({ emails: [] }),
);
const res = await app.request(`/${FEED_ID}/999`, {}, env as any);
expect(res.status).toBe(404);
expect(await res.text()).toContain("Entry not found");
});
it("returns 404 when entryId is not a number", async () => {
const app = makeApp();
const res = await app.request(`/${FEED_ID}/not-a-number`, {}, env as any);
expect(res.status).toBe(404);
});
it("returns 200 with HTML for valid entry", async () => {
await seedFeed(env);
const app = makeApp();
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("text/html");
});
it("includes email subject in HTML title", async () => {
await seedFeed(env);
const app = makeApp();
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
const body = await res.text();
expect(body).toContain("Test Subject");
});
it("includes email content in HTML body", async () => {
await seedFeed(env);
const app = makeApp();
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
const body = await res.text();
expect(body).toContain("<p>Email body</p>");
});
it("includes sender in HTML", async () => {
await seedFeed(env);
const app = makeApp();
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
const body = await res.text();
expect(body).toContain("[email protected]");
});
it("sets Content-Security-Policy header", async () => {
await seedFeed(env);
const app = makeApp();
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
expect(res.headers.get("Content-Security-Policy")).toContain(
"default-src 'none'",
);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { Context } from "hono";
import { html, raw } from "hono/html";
import { Env, FeedMetadata, EmailData } from "../types";
import { processEmailContent } from "../utils/html-processor";
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
const feedId = c.req.param("feedId");
const receivedAt = parseInt(c.req.param("entryId") ?? "", 10);
if (!feedId || isNaN(receivedAt)) {
return new Response("Not Found", { status: 404 });
}
const emailStorage = c.env.EMAIL_STORAGE;
const feedMetadata = (await emailStorage.get(
`feed:${feedId}:metadata`,
"json",
)) as FeedMetadata | null;
if (!feedMetadata) {
return new Response("Feed not found", { status: 404 });
}
const metaEntry = feedMetadata.emails.find(
(e) => e.receivedAt === receivedAt,
);
if (!metaEntry) {
return new Response("Entry not found", { status: 404 });
}
const emailData = (await emailStorage.get(
metaEntry.key,
"json",
)) as EmailData | null;
if (!emailData) {
return new Response("Entry not found", { status: 404 });
}
c.header(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'",
);
return c.html(
html`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>${emailData.subject}</title>
<style>
body {
font-family: sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 1rem;
}
.meta {
color: #666;
font-size: 0.875rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid #eee;
padding-bottom: 0.75rem;
}
.meta dt {
display: inline;
font-weight: bold;
}
.meta dd {
display: inline;
margin: 0 1rem 0 0.25rem;
}
</style>
</head>
<body>
<h1>${emailData.subject}</h1>
<dl class="meta">
<dt>From:</dt>
<dd>${emailData.from}</dd>
<dt>Date:</dt>
<dd>${new Date(emailData.receivedAt).toUTCString()}</dd>
</dl>
<div class="content">
${raw(processEmailContent(emailData.content))}
</div>
</body>
</html>`,
);
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach } from "vitest";
import "../test/setup";
import { createMockEnv, MockR2 } from "../test/setup";
import { Hono } from "hono";
import { handle as handleFiles } from "./files";
async function request(
env: ReturnType<typeof createMockEnv>,
path: string,
): Promise<Response> {
const app = new Hono();
const files = new Hono();
files.get("/:attachmentId/:filename", handleFiles);
app.route("/files", files);
return app.request(path, {}, env as any);
}
describe("GET /files/:attachmentId/:filename", () => {
let envNoR2: ReturnType<typeof createMockEnv>;
let envWithR2: ReturnType<typeof createMockEnv>;
let mockR2: MockR2;
beforeEach(() => {
envNoR2 = createMockEnv();
envWithR2 = createMockEnv({ withR2: true });
mockR2 = (envWithR2 as any).ATTACHMENT_BUCKET as unknown as MockR2;
});
it("returns 404 when ATTACHMENT_BUCKET is not configured", async () => {
const res = await request(envNoR2, "/files/some-id/file.pdf");
expect(res.status).toBe(404);
expect(await res.text()).toContain("not configured");
});
it("returns 404 when attachment ID is not found in R2", async () => {
const res = await request(envWithR2, "/files/unknown-id/file.pdf");
expect(res.status).toBe(404);
expect(await res.text()).toBe("Not found");
});
it("returns 200 with stored content when attachment exists", async () => {
const content = new TextEncoder().encode("PDF content")
.buffer as ArrayBuffer;
await mockR2.put("test-uuid", content, {
httpMetadata: { contentType: "application/pdf" },
});
const res = await request(envWithR2, "/files/test-uuid/report.pdf");
expect(res.status).toBe(200);
});
it("returns correct Content-Type from stored httpMetadata", async () => {
const content = new TextEncoder().encode("data").buffer as ArrayBuffer;
await mockR2.put("img-uuid", content, {
httpMetadata: { contentType: "image/png" },
});
const res = await request(envWithR2, "/files/img-uuid/photo.png");
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("image/png");
});
it("sets Cache-Control immutable header", async () => {
const content = new TextEncoder().encode("data").buffer as ArrayBuffer;
await mockR2.put("cache-uuid", content, {
httpMetadata: { contentType: "application/pdf" },
});
const res = await request(envWithR2, "/files/cache-uuid/doc.pdf");
expect(res.headers.get("Cache-Control")).toBe(
"public, max-age=31536000, immutable",
);
});
it("sets Content-Disposition from httpMetadata when present", async () => {
const content = new TextEncoder().encode("data").buffer as ArrayBuffer;
await mockR2.put("disp-uuid", content, {
httpMetadata: {
contentType: "application/pdf",
contentDisposition: 'attachment; filename="stored.pdf"',
},
});
const res = await request(envWithR2, "/files/disp-uuid/other.pdf");
expect(res.headers.get("Content-Disposition")).toBe(
'attachment; filename="stored.pdf"',
);
});
it("falls back to URL filename for Content-Disposition when not in httpMetadata", async () => {
const content = new TextEncoder().encode("data").buffer as ArrayBuffer;
await mockR2.put("fallback-uuid", content, {
httpMetadata: { contentType: "text/plain" },
});
const res = await request(
envWithR2,
"/files/fallback-uuid/hello%20world.txt",
);
expect(res.headers.get("Content-Disposition")).toBe(
'attachment; filename="hello world.txt"',
);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { Context } from "hono";
import { Env } from "../types";
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
if (!c.env.ATTACHMENT_BUCKET) {
return new Response("Attachment storage not configured", { status: 404 });
}
const attachmentId = c.req.param("attachmentId");
const filename = c.req.param("filename");
if (!attachmentId || !filename) {
return new Response("Not found", { status: 404 });
}
const object = await c.env.ATTACHMENT_BUCKET.get(attachmentId);
if (!object) {
return new Response("Not found", { status: 404 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);
headers.set("Cache-Control", "public, max-age=31536000, immutable");
if (!headers.get("Content-Disposition")) {
headers.set(
"Content-Disposition",
`attachment; filename="${decodeURIComponent(filename)}"`,
);
}
return new Response(object.body, { headers });
}
+326
View File
@@ -0,0 +1,326 @@
import { describe, it, expect } from "vitest";
import { Hono } from "hono";
import { http, HttpResponse } from "msw";
import { server, createMockEnv } from "../test/setup";
import { hubRouter } from "./hub";
function makeApp() {
const app = new Hono();
app.route("/hub", hubRouter);
return app;
}
function hubBody(fields: Record<string, string>): Request {
const body = new URLSearchParams(fields).toString();
return new Request("http://localhost/hub", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
}
describe("POST /hub — input validation", () => {
it("returns 400 when hub.mode is missing", async () => {
const app = makeApp();
const env = createMockEnv();
const res = await app.request(
"/hub",
hubBody({
"hub.topic": `https://${env.DOMAIN}/rss/feed1`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(400);
});
it("returns 400 when hub.topic is missing", async () => {
const app = makeApp();
const env = createMockEnv();
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(400);
});
it("returns 400 when hub.callback is missing", async () => {
const app = makeApp();
const env = createMockEnv();
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/rss/feed1`,
}),
env,
);
expect(res.status).toBe(400);
});
it("returns 400 for unknown hub.mode", async () => {
const app = makeApp();
const env = createMockEnv();
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "ping",
"hub.topic": `https://${env.DOMAIN}/rss/feed1`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(400);
});
it("returns 400 when hub.callback is not HTTPS", async () => {
const app = makeApp();
const env = createMockEnv();
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/rss/feed1`,
"hub.callback": "http://cb.example/sub",
}),
env,
);
expect(res.status).toBe(400);
const body = await res.text();
expect(body).toContain("HTTPS");
});
it("returns 400 when hub.callback is not a valid URL", async () => {
const app = makeApp();
const env = createMockEnv();
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/rss/feed1`,
"hub.callback": "not-a-url",
}),
env,
);
expect(res.status).toBe(400);
});
it("returns 400 when hub.topic does not match this hub's domain", async () => {
const app = makeApp();
const env = createMockEnv();
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": "https://other.example/rss/feed1",
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(400);
});
it("returns 400 when hub.topic uses an unsupported path (not rss or atom)", async () => {
const app = makeApp();
const env = createMockEnv();
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/feed/feed1`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(400);
});
it("returns 400 when hub.secret exceeds 200 bytes", async () => {
const app = makeApp();
const env = createMockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({ title: "Feed 1" }),
);
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/rss/feed1`,
"hub.callback": "https://cb.example/sub",
"hub.secret": "x".repeat(201),
}),
env,
);
expect(res.status).toBe(400);
});
});
describe("POST /hub — subscribe", () => {
it("returns 202 for valid subscribe request", async () => {
const app = makeApp();
const env = createMockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({ title: "Feed 1" }),
);
server.use(
http.get("https://cb.example/sub", ({ request }) => {
const challenge =
new URL(request.url).searchParams.get("hub.challenge") ?? "";
return HttpResponse.text(challenge);
}),
);
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/rss/feed1`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(202);
});
it("accepts hub.lease_seconds within range", async () => {
const app = makeApp();
const env = createMockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({ title: "Feed 1" }),
);
server.use(
http.get("https://cb.example/sub", ({ request }) => {
const challenge =
new URL(request.url).searchParams.get("hub.challenge") ?? "";
return HttpResponse.text(challenge);
}),
);
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/rss/feed1`,
"hub.callback": "https://cb.example/sub",
"hub.lease_seconds": "3600",
}),
env,
);
expect(res.status).toBe(202);
});
it("returns 404 when feed does not exist", async () => {
const app = makeApp();
const env = createMockEnv();
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/rss/nonexistent`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(404);
});
it("returns 202 for valid Atom subscribe request", async () => {
const app = makeApp();
const env = createMockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({ title: "Feed 1" }),
);
server.use(
http.get("https://cb.example/sub", ({ request }) => {
const challenge =
new URL(request.url).searchParams.get("hub.challenge") ?? "";
return HttpResponse.text(challenge);
}),
);
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/atom/feed1`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(202);
});
it("returns 404 for Atom topic when feed does not exist", async () => {
const app = makeApp();
const env = createMockEnv();
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "subscribe",
"hub.topic": `https://${env.DOMAIN}/atom/nonexistent`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(404);
});
});
describe("POST /hub — unsubscribe", () => {
it("returns 202 for valid RSS unsubscribe request", async () => {
const app = makeApp();
const env = createMockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({ title: "Feed 1" }),
);
server.use(
http.get("https://cb.example/sub", ({ request }) => {
const challenge =
new URL(request.url).searchParams.get("hub.challenge") ?? "";
return HttpResponse.text(challenge);
}),
);
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "unsubscribe",
"hub.topic": `https://${env.DOMAIN}/rss/feed1`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(202);
});
it("returns 202 for valid Atom unsubscribe request", async () => {
const app = makeApp();
const env = createMockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({ title: "Feed 1" }),
);
server.use(
http.get("https://cb.example/sub", ({ request }) => {
const challenge =
new URL(request.url).searchParams.get("hub.challenge") ?? "";
return HttpResponse.text(challenge);
}),
);
const res = await app.request(
"/hub",
hubBody({
"hub.mode": "unsubscribe",
"hub.topic": `https://${env.DOMAIN}/atom/feed1`,
"hub.callback": "https://cb.example/sub",
}),
env,
);
expect(res.status).toBe(202);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { Hono } from "hono";
import { Env } from "../types";
import {
verifyAndStoreSubscription,
verifyAndDeleteSubscription,
} from "../utils/websub";
import { waitUntilSafe } from "../utils/worker";
import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants";
import { feedTopicPattern } from "../utils/urls";
type AppEnv = { Bindings: Env };
export const hubRouter = new Hono<AppEnv>();
hubRouter.post("/", async (c) => {
const env = c.env;
let form: FormData;
try {
form = await c.req.formData();
} catch {
return c.text(
"Bad Request: expected application/x-www-form-urlencoded",
400,
);
}
const mode = form.get("hub.mode");
const topic = form.get("hub.topic");
const callbackUrl = form.get("hub.callback");
if (!mode || !topic || !callbackUrl) {
return c.text(
"Bad Request: hub.mode, hub.topic and hub.callback are required",
400,
);
}
if (
typeof mode !== "string" ||
typeof topic !== "string" ||
typeof callbackUrl !== "string"
) {
return c.text("Bad Request: unexpected field types", 400);
}
if (mode !== "subscribe" && mode !== "unsubscribe") {
return c.text(
"Bad Request: hub.mode must be subscribe or unsubscribe",
400,
);
}
let parsedCallback: URL;
try {
parsedCallback = new URL(callbackUrl);
} catch {
return c.text("Bad Request: hub.callback must be a valid URL", 400);
}
if (parsedCallback.protocol !== "https:") {
return c.text("Bad Request: hub.callback must use HTTPS", 400);
}
// Validate that topic matches a known RSS or Atom feed on this hub
const topicPattern = feedTopicPattern(env);
const match = topic.match(topicPattern);
if (!match) {
return c.text(
"Bad Request: hub.topic must be an RSS or Atom feed URL on this hub",
400,
);
}
const format = match[1] as "rss" | "atom";
const feedId = match[2];
// Verify the feed exists before accepting any subscription
const feedConfig = await env.EMAIL_STORAGE.get(
`feed:${feedId}:config`,
"json",
);
if (!feedConfig) {
return c.text("Not Found: feed does not exist", 404);
}
const secret = form.get("hub.secret") || undefined; // "" → undefined
if (secret && secret.length > 200) {
return c.text("Bad Request: hub.secret must be under 200 bytes", 400);
}
const rawLease = parseInt(form.get("hub.lease_seconds") ?? "", 10);
const leaseSeconds = isNaN(rawLease)
? DEFAULT_LEASE_SECONDS
: Math.min(Math.max(rawLease, 1), MAX_LEASE_SECONDS);
// Return 202 immediately; verification is async
if (mode === "subscribe") {
waitUntilSafe(
c,
verifyAndStoreSubscription(
feedId,
callbackUrl as string,
secret as string | undefined,
leaseSeconds,
format,
env,
),
);
} else {
waitUntilSafe(
c,
verifyAndDeleteSubscription(feedId, callbackUrl as string, env),
);
}
return c.text("Accepted", 202);
});
+282
View File
@@ -0,0 +1,282 @@
import { describe, it, expect, beforeEach } from "vitest";
import { http, HttpResponse } from "msw";
import worker from "../index";
import { server, createMockEnv, MockR2 } from "../test/setup";
import type { Env } from "../types";
import type { ForwardEmailPayload } from "../lib/forwardemail";
const AUTHORIZED_IP = "138.197.213.185"; // first fallback IP
const DOMAIN = "test.getmynews.app";
const VALID_FEED_ID = "apple.mountain.42";
const VALID_TO = `${VALID_FEED_ID}@${DOMAIN}`;
function makeRequest(
payload: Partial<ForwardEmailPayload> | null,
ip = AUTHORIZED_IP,
): Request {
return new Request(`https://${DOMAIN}/api/inbound`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"CF-Connecting-IP": ip,
},
body: payload === null ? "not-json{{{" : JSON.stringify(payload),
});
}
function makePayload(
overrides: Partial<ForwardEmailPayload> = {},
): ForwardEmailPayload {
return {
recipients: [VALID_TO],
from: {
value: [{ address: "[email protected]", name: "Sender" }],
text: "Sender <[email protected]>",
},
subject: "Test Subject",
html: "<p>Hello</p>",
...overrides,
};
}
// Stub the ForwardEmail IP lookup so tests work without network access.
// Returns a minimal list that includes AUTHORIZED_IP.
function stubForwardEmailIps() {
server.use(
http.get("https://forwardemail.net/ips/v4.json", () =>
HttpResponse.json([
{
hostname: "mx1.forwardemail.net",
ipv4: [AUTHORIZED_IP],
updated: "",
},
]),
),
);
}
describe("POST /api/inbound — IP middleware", () => {
let env: Env;
beforeEach(async () => {
env = createMockEnv() as unknown as Env;
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: [] }),
);
});
it("returns 401 when IP is not in the ForwardEmail allowlist", async () => {
stubForwardEmailIps();
const res = await worker.fetch(makeRequest(makePayload(), "1.2.3.4"), env);
expect(res.status).toBe(401);
});
it("passes through to the handler when IP is authorised", async () => {
stubForwardEmailIps();
const res = await worker.fetch(
makeRequest(makePayload(), AUTHORIZED_IP),
env,
);
expect(res.status).toBe(200);
});
it("falls back to hardcoded IPs and accepts request when ForwardEmail API is down", async () => {
server.use(
http.get("https://forwardemail.net/ips/v4.json", () =>
HttpResponse.error(),
),
);
// AUTHORIZED_IP is in the hardcoded fallback list
const res = await worker.fetch(
makeRequest(makePayload(), AUTHORIZED_IP),
env,
);
expect(res.status).toBe(200);
});
});
describe("POST /api/inbound — handler logic", () => {
let env: Env;
beforeEach(() => {
stubForwardEmailIps();
env = createMockEnv() as unknown as Env;
});
it("returns 500 on malformed JSON body", async () => {
const res = await worker.fetch(makeRequest(null), env);
expect(res.status).toBe(500);
});
it("returns 400 when recipient address has no valid feed ID", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const res = await worker.fetch(
makeRequest(makePayload({ recipients: ["[email protected]"] })),
env,
);
expect(res.status).toBe(400);
});
it("returns 404 when feed does not exist in KV", async () => {
const res = await worker.fetch(makeRequest(makePayload()), env);
expect(res.status).toBe(404);
});
it("returns 403 when sender is not in the allowlist", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["[email protected]"] }),
);
const res = await worker.fetch(makeRequest(makePayload()), env);
expect(res.status).toBe(403);
});
it("returns 200 when sender matches allowlist by exact address", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["[email protected]"] }),
);
const res = await worker.fetch(makeRequest(makePayload()), env);
expect(res.status).toBe(200);
});
it("returns 200 when sender matches allowlist by domain", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["example.com"] }),
);
const res = await worker.fetch(makeRequest(makePayload()), env);
expect(res.status).toBe(200);
});
it("returns 200 when allowlist is empty (open feed)", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: [] }),
);
const res = await worker.fetch(makeRequest(makePayload()), env);
expect(res.status).toBe(200);
});
it("stores email data and metadata in KV on success", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const res = await worker.fetch(
makeRequest(makePayload({ subject: "Hello KV" })),
env,
);
expect(res.status).toBe(200);
const metadata = (await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
{ type: "json" },
)) as any;
expect(metadata.emails).toHaveLength(1);
expect(metadata.emails[0].subject).toBe("Hello KV");
const emailData = (await env.EMAIL_STORAGE.get(metadata.emails[0].key, {
type: "json",
})) as any;
expect(emailData.subject).toBe("Hello KV");
expect(emailData.from).toContain("[email protected]");
});
it("extracts sender from from.text when from.value is absent", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({ allowed_senders: ["[email protected]"] }),
);
const payload = makePayload({
from: { text: "[email protected]" },
});
const res = await worker.fetch(makeRequest(payload), env);
expect(res.status).toBe(200);
});
it("uses HTML body when available, falls back to text", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const htmlRes = await worker.fetch(
makeRequest(makePayload({ html: "<b>rich</b>", text: "plain" })),
env,
);
expect(htmlRes.status).toBe(200);
const htmlMeta = (await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
{ type: "json" },
)) as any;
const htmlEmail = (await env.EMAIL_STORAGE.get(htmlMeta.emails[0].key, {
type: "json",
})) as any;
expect(htmlEmail.content).toBe("<b>rich</b>");
});
});
describe("POST /api/inbound — attachment upload", () => {
let env: Env;
beforeEach(() => {
stubForwardEmailIps();
env = createMockEnv({ withR2: true }) as unknown as Env;
});
it("uploads attachments to R2 and records ids in metadata", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const payload = makePayload({
attachments: [
{
filename: "doc.pdf",
contentType: "application/pdf",
content: { type: "Buffer", data: [80, 68, 70] },
},
],
});
const res = await worker.fetch(makeRequest(payload), env);
expect(res.status).toBe(200);
const metadata = (await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
{ type: "json" },
)) as any;
expect(metadata.emails[0].attachmentIds).toHaveLength(1);
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
const attachmentId = metadata.emails[0].attachmentIds[0];
expect(mockR2._has(attachmentId)).toBe(true);
});
it("skips R2 when attachment content is null", async () => {
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
const payload = makePayload({
attachments: [
{
filename: "empty.txt",
contentType: "text/plain",
content: undefined,
},
],
});
const res = await worker.fetch(makeRequest(payload), env);
expect(res.status).toBe(200);
const metadata = (await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
{ type: "json" },
)) as any;
expect(metadata.emails[0].attachmentIds).toBeUndefined();
});
});
+9 -151
View File
@@ -1,84 +1,11 @@
import { Context } from "hono"; import { Context } from "hono";
import { EmailParser } from "../utils/email-parser"; import { Env } from "../types";
import { Env, FeedConfig, FeedMetadata } from "../types"; import { ForwardEmailPayload, handleForwardEmail } from "../lib/forwardemail";
// Interface for ForwardEmail.net webhook payload export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
interface ForwardEmailPayload {
recipients?: string[];
from?: {
value?: Array<{ address?: string; name?: string }>;
text?: string;
html?: string;
};
subject?: string;
text?: string;
html?: string;
date?: string;
messageId?: string;
headerLines?: Array<{ key: string; line: string }>;
headers?: string;
raw?: string;
attachments?: Array<any>;
}
function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
function extractIncomingSenderAddresses(
payload: ForwardEmailPayload,
): string[] {
const valueEntries = payload.from?.value || [];
const structuredAddresses = valueEntries
.map((entry) => entry.address || "")
.map(normalizeEmail)
.filter(Boolean);
if (structuredAddresses.length > 0) {
return Array.from(new Set(structuredAddresses));
}
// Fallback parser for plain text like "Name <[email protected]>"
const fromText = payload.from?.text || "";
const matches =
fromText.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || [];
return Array.from(new Set(matches.map(normalizeEmail)));
}
function senderMatchesAllowlist(
sender: string,
allowedSender: string,
): boolean {
const normalizedSender = normalizeEmail(sender);
const normalizedAllowed = normalizeEmail(allowedSender);
if (!normalizedAllowed) {
return false;
}
if (normalizedAllowed.includes("@")) {
return normalizedSender === normalizedAllowed;
}
const senderDomain = normalizedSender.split("@")[1] || "";
const normalizedDomain = normalizedAllowed.startsWith("@")
? normalizedAllowed.slice(1)
: normalizedAllowed;
return senderDomain === normalizedDomain;
}
/**
* Handle incoming emails from ForwardEmail.net webhook
*/
export async function handle(c: Context): Promise<Response> {
try { try {
// Type assertion for environment variables
const env = c.env as unknown as Env;
// Parse the incoming JSON payload
const payload: ForwardEmailPayload = await c.req.json(); const payload: ForwardEmailPayload = await c.req.json();
// Log basic information about the incoming email
console.log("Received email:", { console.log("Received email:", {
to: payload.recipients?.[0], to: payload.recipients?.[0],
from: payload.from?.text || "Unknown", from: payload.from?.text || "Unknown",
@@ -86,82 +13,13 @@ export async function handle(c: Context): Promise<Response> {
contentType: payload.html ? "HTML" : "Text", contentType: payload.html ? "HTML" : "Text",
}); });
// Extract feed ID from email address (e.g., [email protected] -> apple.mountain.42) let ctx: ExecutionContext | undefined;
const toAddress = payload.recipients?.[0] || ""; try {
const feedId = EmailParser.extractFeedId(toAddress); ctx = c.executionCtx;
} catch {
if (!feedId) { // No ExecutionContext in this environment (e.g. tests); WebSub notifications will be skipped
console.error(`Invalid email address format: ${toAddress}`);
return new Response("Invalid email address format", { status: 400 });
} }
return handleForwardEmail(payload, c.env, ctx);
// Check if the feed exists by looking up the feed configuration
const feedConfigKey = `feed:${feedId}:config`;
const feedConfig = (await env.EMAIL_STORAGE.get(
feedConfigKey,
"json",
)) as FeedConfig | null;
if (!feedConfig) {
console.error(
`Feed with ID ${feedId} does not exist or has been deleted`,
);
return new Response("Feed does not exist", { status: 404 });
}
const allowedSenders = (feedConfig.allowed_senders || [])
.map(normalizeEmail)
.filter(Boolean);
if (allowedSenders.length > 0) {
const incomingSenders = extractIncomingSenderAddresses(payload);
const senderAllowed = incomingSenders.some((sender) =>
allowedSenders.some((allowedSender) =>
senderMatchesAllowlist(sender, allowedSender),
),
);
if (!senderAllowed) {
console.warn(
`Rejected email for feed ${feedId}; sender not in allowlist`,
{
incomingSenders,
allowedSenders,
},
);
return new Response("Sender not allowed for this feed", {
status: 403,
});
}
}
// Parse the email using our simplified parser
const emailData = EmailParser.parseForwardEmailPayload(payload);
// Generate a unique key for this email in KV storage
const emailKey = `feed:${feedId}:${Date.now()}`;
// Store the email data in KV
await env.EMAIL_STORAGE.put(emailKey, JSON.stringify(emailData));
// Get existing feed metadata
const feedMetadataKey = `feed:${feedId}:metadata`;
const feedMetadata = ((await env.EMAIL_STORAGE.get(
feedMetadataKey,
"json",
)) || { emails: [] }) as FeedMetadata;
// Add this email to the feed metadata
feedMetadata.emails.unshift({
key: emailKey,
subject: emailData.subject,
receivedAt: emailData.receivedAt,
});
// Store updated feed metadata
await env.EMAIL_STORAGE.put(feedMetadataKey, JSON.stringify(feedMetadata));
console.log(`Successfully processed email for feed ${feedId}`);
return new Response("Email processed successfully", { status: 200 });
} catch (error) { } catch (error) {
console.error("Error processing email:", error); console.error("Error processing email:", error);
return new Response("Error processing email", { status: 500 }); return new Response("Error processing email", { status: 500 });
+34 -59
View File
@@ -1,70 +1,45 @@
import { Context } from 'hono'; import { Context } from "hono";
import { Env, FeedConfig, FeedMetadata, EmailData } from '../types'; import { Env } from "../types";
import { generateRssFeed } from '../utils/feed-generator'; import { generateRssFeed } from "../utils/feed-generator";
import { fetchFeedData } from "../utils/feed-fetcher";
import { baseUrl, feedRssUrl } from "../utils/urls";
/** export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
* Generates an RSS feed for a specific feed ID
*/
export async function handle(c: Context): Promise<Response> {
try { try {
// Type assertion for environment variables const feedId = c.req.param("feedId");
const env = c.env as unknown as Env;
// Extract the feed ID from the route params
const feedId = c.req.param('feedId');
if (!feedId) { if (!feedId) {
return new Response('Feed ID is required', { status: 400 }); return new Response("Feed ID is required", { status: 400 });
} }
// Get the KV namespace const feedData = await fetchFeedData(feedId, c.env);
const emailStorage = env.EMAIL_STORAGE; if (!feedData) {
return new Response("Feed not found", { status: 404 });
// Check if the feed exists
const feedMetadataKey = `feed:${feedId}:metadata`;
const feedMetadata = await emailStorage.get(feedMetadataKey, 'json') as FeedMetadata | null;
if (!feedMetadata) {
return new Response('Feed not found', { status: 404 });
} }
// Get feed configuration (title, description, etc.) const base = baseUrl(c.env);
const feedConfigKey = `feed:${feedId}:config`; const selfUrl = new URL(c.req.url).origin + `/rss/${feedId}`;
const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null || { const rssXml = generateRssFeed(
title: `Newsletter Feed ${feedId}`, feedData.feedConfig,
description: 'Converted email newsletter', feedData.emails,
site_url: `https://${env.DOMAIN}/rss/${feedId}`, base,
feed_url: `https://${env.DOMAIN}/rss/${feedId}`, feedId,
language: 'en', selfUrl,
created_at: Date.now() );
}; const linkHeader = [
`<${base}/hub>; rel="hub"`,
// Get the emails for this feed (up to the last 20) `<${feedRssUrl(feedId, c.env)}>; rel="self"`,
const emails = feedMetadata.emails.slice(0, 20); ].join(", ");
const emailsData: EmailData[] = [];
// Fetch all email content
for (const email of emails) {
const emailData = await emailStorage.get(email.key, 'json') as EmailData | null;
if (emailData) {
emailsData.push(emailData);
}
}
// Generate the RSS feed XML
const baseUrl = `https://${env.DOMAIN}`;
const rssXml = generateRssFeed(feedConfig, emailsData, baseUrl);
// Return the RSS feed with appropriate content type
return new Response(rssXml, { return new Response(rssXml, {
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/rss+xml', "Content-Type": "application/rss+xml",
'Cache-Control': 'max-age=1800' // 30 minutes cache "Cache-Control": "max-age=1800",
} Link: linkHeader,
},
}); });
} catch (error) { } catch (error) {
console.error('Error generating RSS feed:', error); console.error("Error generating RSS feed:", error);
return new Response('Internal Server Error', { status: 500 }); return new Response("Internal Server Error", { status: 500 });
} }
} }
+831
View File
@@ -0,0 +1,831 @@
// Client-side script for the admin dashboard (feeds table).
// Compiled by scripts/build-client.mjs — do not import DOM types from here in Worker code.
let FEED_ROWS: HTMLElement[] = [];
let FEED_CHECKBOXES: HTMLInputElement[] = [];
let FEED_SELECTED_COUNT_EL: HTMLElement | null = null;
let FEED_MATCH_COUNT_EL: HTMLElement | null = null;
let FEED_TOTAL_COUNT_EL: HTMLElement | null = null;
let FEED_BULK_DELETE_BUTTON_EL: HTMLButtonElement | null = null;
let FEED_SELECT_ALL_EL: HTMLInputElement | null = null;
let FEED_FILTER_TIMER: ReturnType<typeof setTimeout> | null = null;
let FEED_BULK_DELETE_IN_PROGRESS = false;
let FEED_SORT_KEY = "title";
let FEED_SORT_DIR = "asc";
const FEED_COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: "base",
});
function initFeedUI(): void {
FEED_ROWS = Array.from(document.querySelectorAll<HTMLElement>(".feed-row"));
FEED_CHECKBOXES = Array.from(
document.querySelectorAll<HTMLInputElement>(".feed-select"),
);
FEED_SELECTED_COUNT_EL = document.getElementById("selected-feed-count");
FEED_MATCH_COUNT_EL = document.getElementById("feed-match-count");
FEED_TOTAL_COUNT_EL = document.getElementById("feed-total-count");
FEED_BULK_DELETE_BUTTON_EL = document.getElementById(
"bulk-delete-feeds-button",
) as HTMLButtonElement | null;
FEED_SELECT_ALL_EL = document.getElementById(
"select-all-feeds",
) as HTMLInputElement | null;
setupFeedTableResizing();
setupFeedTableSorting();
setupFeedDeleteButtons();
updateFeedMatchCount();
updateFeedSelectionState();
}
function updateFeedMatchCount(): void {
if (!FEED_MATCH_COUNT_EL) return;
const total = FEED_ROWS.length;
const visible = FEED_ROWS.filter((row) => !row.hidden).length;
const query = (
(document.getElementById("feed-search") as HTMLInputElement | null)
?.value || ""
).trim();
FEED_MATCH_COUNT_EL.textContent = query
? "Showing " + visible + " of " + total
: "Showing " + total;
}
function scheduleFeedFilter(): void {
if (FEED_FILTER_TIMER) {
clearTimeout(FEED_FILTER_TIMER);
}
FEED_FILTER_TIMER = setTimeout(filterFeedRows, 120);
}
function getSortValue(row: HTMLElement, key: string): string {
const prop = "sort" + key.charAt(0).toUpperCase() + key.slice(1);
return row.dataset && row.dataset[prop] ? row.dataset[prop]! : "";
}
function updateFeedSortIndicators(table: Element): void {
const headerCells = Array.from(
table.querySelectorAll<HTMLElement>("th[data-sort-key]"),
);
headerCells.forEach((th) => {
const key = th.getAttribute("data-sort-key") || "";
const indicator = th.querySelector(".sort-indicator");
const active = key === FEED_SORT_KEY;
if (indicator) {
indicator.textContent = active
? FEED_SORT_DIR === "asc"
? "^"
: "v"
: "";
}
th.setAttribute(
"aria-sort",
active ? (FEED_SORT_DIR === "asc" ? "ascending" : "descending") : "none",
);
});
}
function sortFeedTableBy(key: string): void {
const table = document.querySelector("table.table-feeds");
const tbody = document.getElementById("feed-table-body");
if (!table || !tbody) return;
if (FEED_SORT_KEY === key) {
FEED_SORT_DIR = FEED_SORT_DIR === "asc" ? "desc" : "asc";
} else {
FEED_SORT_KEY = key;
FEED_SORT_DIR = "asc";
}
const dirMultiplier = FEED_SORT_DIR === "asc" ? 1 : -1;
const rows = Array.from(tbody.querySelectorAll<HTMLElement>(".feed-row"));
rows.sort((a, b) => {
const av = getSortValue(a, FEED_SORT_KEY);
const bv = getSortValue(b, FEED_SORT_KEY);
return dirMultiplier * FEED_COLLATOR.compare(av, bv);
});
const fragment = document.createDocumentFragment();
rows.forEach((row) => fragment.appendChild(row));
tbody.appendChild(fragment);
updateFeedSortIndicators(table);
}
function setupFeedTableSorting(): void {
const table = document.querySelector("table.table-feeds");
if (!table) return;
table
.querySelectorAll<HTMLButtonElement>("button.th-button[data-sort-key]")
.forEach((button) => {
button.addEventListener("click", () => {
const key = button.getAttribute("data-sort-key") || "";
if (!key) return;
sortFeedTableBy(key);
});
});
updateFeedSortIndicators(table);
}
function setupFeedTableResizing(): void {
const table = document.querySelector("table.table-feeds");
if (!table) return;
const storageKey = "email-to-rss.admin.feedsTable.colWidths";
const minWidths: Record<string, number> = {
title: 220,
feedId: 120,
email: 160,
rss: 160,
actions: 160,
};
const defaultWidths: Record<string, number> = {
title: 340,
feedId: 160,
email: 220,
rss: 220,
actions: 200,
};
const cols = Array.from(table.querySelectorAll<HTMLElement>("colgroup col"));
const colByKey: Record<string, HTMLElement> = {};
cols.forEach((col) => {
const key = col.getAttribute("data-col");
if (key) colByKey[key] = col;
});
// Restore widths
try {
const saved = JSON.parse(
localStorage.getItem(storageKey) || "{}",
) as Record<string, unknown>;
Object.keys(saved || {}).forEach((key) => {
const px = Number(saved[key]);
if (!colByKey[key] || !Number.isFinite(px)) return;
colByKey[key].style.width = px + "px";
});
} catch {
// Ignore bad localStorage values
}
const persist = () => {
try {
const out: Record<string, number> = {};
Object.keys(colByKey).forEach((key) => {
if (key === "select") return;
const px = parseInt(colByKey[key].style.width || "0", 10);
if (Number.isFinite(px) && px > 0) out[key] = px;
});
localStorage.setItem(storageKey, JSON.stringify(out));
} catch {
// localStorage may be unavailable in some modes; ignore
}
};
type ActiveResize = {
key: string;
col: HTMLElement;
startX: number;
startWidth: number;
};
let active: ActiveResize | null = null;
let rafId = 0;
let pendingWidth = 0;
table.querySelectorAll<HTMLElement>(".col-resizer").forEach((handle) => {
handle.addEventListener("pointerdown", (event: PointerEvent) => {
event.preventDefault();
event.stopPropagation();
const key = handle.getAttribute("data-col");
const col = key ? colByKey[key] : null;
if (!key || !col) return;
const th = handle.closest("th");
const startWidth = th
? th.getBoundingClientRect().width
: parseInt(col.style.width || "0", 10) || 120;
active = { key, col, startX: event.clientX, startWidth };
document.body.classList.add("is-resizing");
handle.setPointerCapture(event.pointerId);
});
handle.addEventListener("pointermove", (event: PointerEvent) => {
if (!active) return;
const minPx = minWidths[active.key] || 120;
const nextWidth = Math.max(
minPx,
Math.round(active.startWidth + (event.clientX - active.startX)),
);
pendingWidth = nextWidth;
if (rafId) return;
rafId = requestAnimationFrame(() => {
active!.col.style.width = pendingWidth + "px";
rafId = 0;
});
});
const finish = () => {
if (!active) return;
active = null;
document.body.classList.remove("is-resizing");
persist();
};
handle.addEventListener("pointerup", finish);
handle.addEventListener("pointercancel", finish);
handle.addEventListener("dblclick", (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const key = handle.getAttribute("data-col");
const col = key ? colByKey[key] : null;
const px = key ? defaultWidths[key] : null;
if (!key || !col || !px) return;
col.style.width = px + "px";
persist();
});
});
}
const DELETE_CONFIRM_LABEL = "Confirm delete";
const DELETE_LOADING_LABEL = "Deleting...";
const DELETE_CONFIRM_TIMEOUT_MS = 4000;
function getDeleteView(): string {
return new URL(window.location.href).searchParams.get("view") || "list";
}
function resetDeleteButton(buttonEl: HTMLButtonElement): void {
if (!buttonEl) return;
buttonEl.classList.remove("is-confirming");
buttonEl.removeAttribute("data-confirming");
buttonEl.disabled = false;
const original =
buttonEl.dataset.originalLabel ||
(buttonEl.textContent || "").trim() ||
"Delete";
buttonEl.innerHTML = original;
}
function animateRowRemoval(row: HTMLElement | null, onDone?: () => void): void {
if (!row) {
if (onDone) onDone();
return;
}
const isListItem = row.tagName.toLowerCase() === "li";
if (isListItem) {
row.style.maxHeight = row.getBoundingClientRect().height + "px";
row.style.overflow = "hidden";
}
row.classList.add("is-removing");
requestAnimationFrame(() => {
if (isListItem) {
row.style.maxHeight = "0px";
row.style.marginTop = "0px";
row.style.marginBottom = "0px";
row.style.paddingTop = "0px";
row.style.paddingBottom = "0px";
}
});
window.setTimeout(() => {
row.remove();
if (onDone) onDone();
}, 240);
}
async function deleteFeedRequest(
feedId: string,
view: string,
): Promise<unknown> {
const res = await fetch(
"/admin/feeds/" +
encodeURIComponent(feedId) +
"/delete?view=" +
encodeURIComponent(view),
{
method: "POST",
headers: {
Accept: "application/json",
},
credentials: "same-origin",
},
);
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
const message =
data && data.error
? String(data.error)
: "Request failed (" + res.status + ")";
throw new Error(message);
}
return data;
}
function refreshFeedRowCache(): void {
FEED_ROWS = Array.from(document.querySelectorAll<HTMLElement>(".feed-row"));
FEED_CHECKBOXES = Array.from(
document.querySelectorAll<HTMLInputElement>(".feed-select"),
);
if (FEED_TOTAL_COUNT_EL) {
FEED_TOTAL_COUNT_EL.textContent = String(FEED_ROWS.length);
}
updateFeedMatchCount();
updateFeedSelectionState();
}
interface ToastHandle {
update?: (msg: string, opts?: Record<string, unknown>) => void;
dismiss?: () => void;
}
declare global {
interface Window {
showToast?: (msg: string, opts?: Record<string, unknown>) => ToastHandle;
parseJsonResponseOrThrow?: (
res: Response,
opts?: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
}
}
function setupFeedDeleteButtons(): void {
const buttons = Array.from(
document.querySelectorAll<HTMLButtonElement>(
'button[data-delete-kind="feed"]',
),
);
buttons.forEach((button) => {
if (button.dataset.deleteReady === "true") return;
button.dataset.deleteReady = "true";
const original = (button.textContent || "").trim() || "Delete";
button.dataset.originalLabel = original;
let confirming = false;
let confirmTimer = 0;
let inFlight = false;
const startConfirm = () => {
confirming = true;
button.classList.add("is-confirming");
button.setAttribute("data-confirming", "true");
button.innerHTML = DELETE_CONFIRM_LABEL;
if (confirmTimer) window.clearTimeout(confirmTimer);
confirmTimer = window.setTimeout(() => {
confirming = false;
resetDeleteButton(button);
}, DELETE_CONFIRM_TIMEOUT_MS);
};
button.addEventListener("click", async (event: MouseEvent) => {
event.preventDefault();
if (inFlight) return;
if (!confirming) {
startConfirm();
return;
}
if (confirmTimer) window.clearTimeout(confirmTimer);
inFlight = true;
setButtonLoading(button, true, DELETE_LOADING_LABEL);
const toast = window.showToast
? window.showToast("Deleting feed...", {
type: "info",
loading: true,
duration: 0,
})
: null;
const feedId = button.getAttribute("data-feed-id") || "";
const view = button.getAttribute("data-view") || getDeleteView();
const row = button.closest<HTMLElement>(".feed-row");
try {
await deleteFeedRequest(feedId, view);
if (toast && toast.update) {
toast.update("Feed deleted.", {
type: "success",
loading: false,
duration: 3200,
});
} else if (window.showToast) {
window.showToast("Feed deleted.", { type: "success" });
}
animateRowRemoval(row, () => {
refreshFeedRowCache();
});
} catch (error) {
const errMsg =
error instanceof Error && error.message
? error.message
: "Unknown error";
if (toast && toast.update) {
toast.update("Delete failed: " + errMsg, {
type: "error",
loading: false,
});
} else if (window.showToast) {
window.showToast("Delete failed: " + errMsg, { type: "error" });
}
setButtonLoading(button, false);
confirming = false;
resetDeleteButton(button);
} finally {
inFlight = false;
if (!row) {
setButtonLoading(button, false);
confirming = false;
resetDeleteButton(button);
}
}
});
button.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Escape" && confirming && !inFlight) {
confirming = false;
if (confirmTimer) window.clearTimeout(confirmTimer);
resetDeleteButton(button);
}
});
});
}
function updateFeedSelectionState(): void {
if (!FEED_CHECKBOXES.length) {
return;
}
const selected = FEED_CHECKBOXES.filter((checkbox) => checkbox.checked);
if (FEED_SELECTED_COUNT_EL) {
FEED_SELECTED_COUNT_EL.textContent = selected.length + " selected";
}
if (FEED_BULK_DELETE_BUTTON_EL) {
FEED_BULK_DELETE_BUTTON_EL.disabled = selected.length === 0;
}
if (FEED_SELECT_ALL_EL) {
const visibleCheckboxes = FEED_CHECKBOXES.filter(
(checkbox) => !(checkbox.closest("tr") as HTMLElement | null)?.hidden,
);
FEED_SELECT_ALL_EL.checked =
visibleCheckboxes.length > 0 &&
visibleCheckboxes.every((checkbox) => checkbox.checked);
}
}
function toggleAllFeeds(checked: boolean): void {
FEED_CHECKBOXES.forEach((checkbox) => {
if (!(checkbox.closest("tr") as HTMLElement | null)?.hidden) {
checkbox.checked = checked;
}
});
updateFeedSelectionState();
}
function setVisibleFeedSelection(checked: boolean): void {
FEED_CHECKBOXES.forEach((checkbox) => {
if (!(checkbox.closest("tr") as HTMLElement | null)?.hidden) {
checkbox.checked = checked;
}
});
updateFeedSelectionState();
}
function selectMatchingFeeds(): void {
setVisibleFeedSelection(true);
}
function clearFeedSelection(): void {
FEED_CHECKBOXES.forEach((checkbox) => {
checkbox.checked = false;
});
updateFeedSelectionState();
}
function filterFeedRows(): void {
const query = (
(document.getElementById("feed-search") as HTMLInputElement | null)
?.value || ""
)
.toLowerCase()
.trim();
FEED_ROWS.forEach((row) => {
const haystack = row.getAttribute("data-search") || "";
row.hidden = !!query && !haystack.includes(query);
});
updateFeedMatchCount();
updateFeedSelectionState();
}
function confirmBulkFeedDelete(): boolean {
const selected = FEED_CHECKBOXES.filter(
(checkbox) => checkbox.checked,
).length;
if (selected === 0) return false;
const query = (
(document.getElementById("feed-search") as HTMLInputElement | null)
?.value || ""
).trim();
const extra =
selected >= 50 && !query
? "\n\nThis is a large delete. Tip: use Search to narrow down spam first."
: "";
return confirm(
"Delete " +
selected +
" selected feed(s)? This disables the feeds immediately. Stored emails are cleaned up best-effort and may take a while." +
extra,
);
}
function setButtonLoading(
buttonEl: HTMLButtonElement | null,
loading: boolean,
label?: string,
): void {
if (!buttonEl) return;
if (loading) {
if (!buttonEl.dataset.originalLabel) {
buttonEl.dataset.originalLabel = (buttonEl.textContent || "").trim();
}
const text = label || "Working...";
buttonEl.classList.add("is-loading");
buttonEl.disabled = true;
buttonEl.innerHTML =
'<span class="spinner" aria-hidden="true"></span>' + text;
return;
}
const original =
buttonEl.dataset.originalLabel || (buttonEl.textContent || "").trim();
buttonEl.classList.remove("is-loading");
buttonEl.innerHTML = original;
}
function removeFeedRowsById(feedIds: string[]): void {
const toRemove = new Set((feedIds || []).map((v) => String(v)));
if (toRemove.size === 0) return;
FEED_ROWS.forEach((row) => {
const checkbox = row.querySelector<HTMLInputElement>("input.feed-select");
const id = checkbox ? checkbox.value : "";
if (toRemove.has(id)) {
row.remove();
}
});
FEED_ROWS = Array.from(document.querySelectorAll<HTMLElement>(".feed-row"));
FEED_CHECKBOXES = Array.from(
document.querySelectorAll<HTMLInputElement>(".feed-select"),
);
if (FEED_TOTAL_COUNT_EL) {
FEED_TOTAL_COUNT_EL.textContent = String(FEED_ROWS.length);
}
}
function onBulkFeedDeleteSubmit(event: Event): boolean {
if (event && event.preventDefault) event.preventDefault();
void bulkDeleteSelectedFeeds();
return false;
}
async function bulkDeleteSelectedFeeds(): Promise<void> {
if (FEED_BULK_DELETE_IN_PROGRESS) return;
const selectedIds = FEED_CHECKBOXES.filter(
(checkbox) => checkbox.checked,
).map((checkbox) => checkbox.value);
if (selectedIds.length === 0) {
if (window.showToast)
window.showToast("No feeds selected.", { type: "info" });
return;
}
if (!confirmBulkFeedDelete()) {
return;
}
FEED_BULK_DELETE_IN_PROGRESS = true;
setButtonLoading(FEED_BULK_DELETE_BUTTON_EL, true, "Deleting...");
const toast = window.showToast
? window.showToast("Deleting " + selectedIds.length + " feed(s)...", {
type: "info",
loading: true,
duration: 0,
})
: null;
const batchSize = 10;
let deletedTotal = 0;
const failed: string[] = [];
try {
for (let i = 0; i < selectedIds.length; i += batchSize) {
const batch = selectedIds.slice(i, i + batchSize);
const res = await fetch("/admin/feeds/bulk-delete", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
credentials: "same-origin",
body: JSON.stringify({ feedIds: batch }),
});
let data: Record<string, unknown> = {};
if (window.parseJsonResponseOrThrow) {
data = await window.parseJsonResponseOrThrow(res, {
prefix: "Bulk feed delete failed",
});
} else {
data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
const message =
data && data.error
? String(data.error)
: "Bulk feed delete failed (HTTP " + res.status + ")";
throw new Error(message);
}
}
const deletedIds = Array.isArray(data.deletedFeedIds)
? (data.deletedFeedIds as string[])
: batch;
const failedIds = Array.isArray(data.failedFeedIds)
? (data.failedFeedIds as string[])
: [];
const failureDetails = Array.isArray(data.failures)
? (data.failures as Array<Record<string, unknown>>)
: [];
removeFeedRowsById(deletedIds);
deletedTotal += deletedIds.length;
if (toast && toast.update) {
const done = Math.min(i + batch.length, selectedIds.length);
toast.update(
"Deleting... (" + done + " of " + selectedIds.length + ")",
{ type: "info" },
);
}
// Keep selection state consistent as rows disappear.
updateFeedMatchCount();
updateFeedSelectionState();
// If a batch fails for some feeds, retry those one-by-one using the bulk-delete
// endpoint with a single id (keeps semantics consistent and avoids hiding active feeds).
if (failedIds.length > 0) {
if (toast && toast.update) {
toast.update(
"Retrying " + failedIds.length + " failed feed(s) one-by-one...",
{ type: "info" },
);
}
const stillFailed: string[] = [];
for (let j = 0; j < failedIds.length; j++) {
const feedId = String(failedIds[j] || "");
if (!feedId) continue;
try {
const retryRes = await fetch("/admin/feeds/bulk-delete", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
credentials: "same-origin",
body: JSON.stringify({ feedIds: [feedId] }),
});
let retryData: Record<string, unknown> = {};
if (window.parseJsonResponseOrThrow) {
retryData = await window.parseJsonResponseOrThrow(retryRes, {
prefix: "Retry delete failed",
});
} else {
retryData = (await retryRes.json().catch(() => ({}))) as Record<
string,
unknown
>;
if (!retryRes.ok) {
const message =
retryData && retryData.error
? String(retryData.error)
: "Retry delete failed (HTTP " + retryRes.status + ")";
throw new Error(message);
}
}
const retryDeleted = Array.isArray(retryData.deletedFeedIds)
? (retryData.deletedFeedIds as string[])
: [];
const retryFailed = Array.isArray(retryData.failedFeedIds)
? (retryData.failedFeedIds as string[])
: [];
if (retryDeleted.includes(feedId)) {
removeFeedRowsById([feedId]);
deletedTotal += 1;
} else if (retryFailed.includes(feedId)) {
stillFailed.push(feedId);
} else {
stillFailed.push(feedId);
}
} catch {
stillFailed.push(feedId);
}
if (toast && toast.update) {
toast.update(
"Retrying... (" + (j + 1) + " of " + failedIds.length + ")",
{ type: "info" },
);
}
}
// Replace failed ids from this batch with only the ones that still failed after retry.
if (stillFailed.length > 0) {
failed.push(...stillFailed);
if (window.showToast && failureDetails.length > 0) {
const first =
failureDetails[0] && failureDetails[0].error
? String(failureDetails[0].error)
: "";
if (first) {
window.showToast("Some feeds failed to delete: " + first, {
type: "error",
});
}
}
}
updateFeedMatchCount();
updateFeedSelectionState();
}
}
if (toast && toast.dismiss) toast.dismiss();
const uniqueFailed = Array.from(
new Set(failed.map((v) => String(v)).filter(Boolean)),
);
if (uniqueFailed.length > 0) {
if (window.showToast) {
window.showToast(
"Deleted " +
deletedTotal +
" feed(s). " +
uniqueFailed.length +
" failed (still visible).",
{ type: "error" },
);
}
} else {
if (window.showToast)
window.showToast("Deleted " + deletedTotal + " feed(s).", {
type: "success",
});
}
} catch (error) {
if (toast && toast.dismiss) toast.dismiss();
if (window.showToast) {
window.showToast(
error instanceof Error && error.message
? error.message
: "Bulk feed delete failed.",
{ type: "error" },
);
}
} finally {
FEED_BULK_DELETE_IN_PROGRESS = false;
setButtonLoading(FEED_BULK_DELETE_BUTTON_EL, false);
updateFeedSelectionState();
}
}
// Expose functions needed by inline HTML event handlers
(window as unknown as Record<string, unknown>).scheduleFeedFilter =
scheduleFeedFilter;
(window as unknown as Record<string, unknown>).toggleAllFeeds = toggleAllFeeds;
(window as unknown as Record<string, unknown>).selectMatchingFeeds =
selectMatchingFeeds;
(window as unknown as Record<string, unknown>).clearFeedSelection =
clearFeedSelection;
(window as unknown as Record<string, unknown>).onBulkFeedDeleteSubmit =
onBulkFeedDeleteSubmit;
document.addEventListener("DOMContentLoaded", () => {
initFeedUI();
});
+632
View File
@@ -0,0 +1,632 @@
// Client-side script for the admin emails page.
// Compiled by scripts/build-client.mjs — do not import DOM types from here in Worker code.
// feedId is read at runtime from window.__APP_CONFIG__ (injected as a config bootstrap script before this one).
const EMAIL_FEED_ID: string =
(window as unknown as { __APP_CONFIG__?: { feedId?: string } }).__APP_CONFIG__
?.feedId ?? "";
let EMAIL_ROWS: HTMLElement[] = [];
let EMAIL_CHECKBOXES: HTMLInputElement[] = [];
let EMAIL_SELECTED_COUNT_EL: HTMLElement | null = null;
let EMAIL_MATCH_COUNT_EL: HTMLElement | null = null;
let EMAIL_TOTAL_COUNT_EL: HTMLElement | null = null;
let EMAIL_BULK_DELETE_BUTTON_EL: HTMLButtonElement | null = null;
let EMAIL_SELECT_ALL_EL: HTMLInputElement | null = null;
let EMAIL_FILTER_TIMER: ReturnType<typeof setTimeout> | null = null;
let EMAIL_BULK_DELETE_IN_PROGRESS = false;
let EMAIL_SORT_KEY = "receivedAt";
let EMAIL_SORT_DIR = "desc";
const EMAIL_COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: "base",
});
function initEmailUI(): void {
EMAIL_ROWS = Array.from(document.querySelectorAll<HTMLElement>(".email-row"));
EMAIL_CHECKBOXES = Array.from(
document.querySelectorAll<HTMLInputElement>(".email-select"),
);
EMAIL_SELECTED_COUNT_EL = document.getElementById("selected-email-count");
EMAIL_MATCH_COUNT_EL = document.getElementById("email-match-count");
EMAIL_TOTAL_COUNT_EL = document.getElementById("email-total-count");
EMAIL_BULK_DELETE_BUTTON_EL = document.getElementById(
"bulk-delete-emails-button",
) as HTMLButtonElement | null;
EMAIL_SELECT_ALL_EL = document.getElementById(
"select-all-emails",
) as HTMLInputElement | null;
setupEmailTableResizing();
setupEmailTableSorting();
setupEmailDeleteButtons();
updateEmailMatchCount();
updateEmailSelectionState();
}
function updateEmailMatchCount(): void {
if (!EMAIL_MATCH_COUNT_EL) return;
const total = EMAIL_ROWS.length;
const visible = EMAIL_ROWS.filter((row) => !row.hidden).length;
const query = (
(document.getElementById("email-search") as HTMLInputElement | null)
?.value || ""
).trim();
EMAIL_MATCH_COUNT_EL.textContent = query
? "Showing " + visible + " of " + total
: "Showing " + total;
}
function scheduleEmailFilter(): void {
if (EMAIL_FILTER_TIMER) clearTimeout(EMAIL_FILTER_TIMER);
EMAIL_FILTER_TIMER = setTimeout(filterEmailRows, 120);
}
function getEmailSortValue(row: HTMLElement, key: string): string {
const prop = "sort" + key.charAt(0).toUpperCase() + key.slice(1);
return row.dataset && row.dataset[prop] ? row.dataset[prop]! : "";
}
function updateEmailSortIndicators(table: Element): void {
Array.from(table.querySelectorAll<HTMLElement>("th[data-sort-key]")).forEach(
(th) => {
const key = th.getAttribute("data-sort-key") || "";
const indicator = th.querySelector(".sort-indicator");
const active = key === EMAIL_SORT_KEY;
if (indicator)
indicator.textContent = active
? EMAIL_SORT_DIR === "asc"
? "^"
: "v"
: "";
th.setAttribute(
"aria-sort",
active
? EMAIL_SORT_DIR === "asc"
? "ascending"
: "descending"
: "none",
);
},
);
}
function sortEmailTableBy(key: string): void {
const table = document.querySelector("table.table-emails");
const tbody = table ? table.querySelector("tbody") : null;
if (!table || !tbody) return;
if (EMAIL_SORT_KEY === key) {
EMAIL_SORT_DIR = EMAIL_SORT_DIR === "asc" ? "desc" : "asc";
} else {
EMAIL_SORT_KEY = key;
EMAIL_SORT_DIR = key === "receivedAt" ? "desc" : "asc";
}
const dir = EMAIL_SORT_DIR === "asc" ? 1 : -1;
const rows = Array.from(tbody.querySelectorAll<HTMLElement>(".email-row"));
rows.sort(
(a, b) =>
dir *
EMAIL_COLLATOR.compare(
getEmailSortValue(a, EMAIL_SORT_KEY),
getEmailSortValue(b, EMAIL_SORT_KEY),
),
);
const frag = document.createDocumentFragment();
rows.forEach((row) => frag.appendChild(row));
tbody.appendChild(frag);
updateEmailSortIndicators(table);
}
function setupEmailTableSorting(): void {
const table = document.querySelector("table.table-emails");
if (!table) return;
table
.querySelectorAll<HTMLButtonElement>("button.th-button[data-sort-key]")
.forEach((btn) => {
btn.addEventListener("click", () => {
const key = btn.getAttribute("data-sort-key");
if (key) sortEmailTableBy(key);
});
});
updateEmailSortIndicators(table);
}
function setupEmailTableResizing(): void {
const table = document.querySelector("table.table-emails");
if (!table) return;
const storageKey = "email-to-rss.admin.emailsTable.colWidths";
const minWidths: Record<string, number> = {
subject: 240,
receivedAt: 180,
actions: 160,
};
const defaultWidths: Record<string, number> = {
subject: 520,
receivedAt: 220,
actions: 200,
};
const cols = Array.from(table.querySelectorAll<HTMLElement>("colgroup col"));
const colByKey: Record<string, HTMLElement> = {};
cols.forEach((col) => {
const key = col.getAttribute("data-col");
if (key) colByKey[key] = col;
});
try {
const saved = JSON.parse(
localStorage.getItem(storageKey) || "{}",
) as Record<string, unknown>;
Object.keys(saved || {}).forEach((key) => {
const px = Number(saved[key]);
if (colByKey[key] && Number.isFinite(px))
colByKey[key].style.width = px + "px";
});
} catch {
/* ignore */
}
const persist = () => {
try {
const out: Record<string, number> = {};
Object.keys(colByKey).forEach((key) => {
if (key === "select") return;
const px = parseInt(colByKey[key].style.width || "0", 10);
if (Number.isFinite(px) && px > 0) out[key] = px;
});
localStorage.setItem(storageKey, JSON.stringify(out));
} catch {
/* ignore */
}
};
type ActiveResize = {
key: string;
col: HTMLElement;
startX: number;
startWidth: number;
};
let active: ActiveResize | null = null;
let rafId = 0;
let pendingWidth = 0;
table.querySelectorAll<HTMLElement>(".col-resizer").forEach((handle) => {
handle.addEventListener("pointerdown", (event: PointerEvent) => {
event.preventDefault();
event.stopPropagation();
const key = handle.getAttribute("data-col");
const col = key ? colByKey[key] : null;
if (!key || !col) return;
const th = handle.closest("th");
const startWidth = th
? th.getBoundingClientRect().width
: parseInt(col.style.width || "0", 10) || 200;
active = { key, col, startX: event.clientX, startWidth };
document.body.classList.add("is-resizing");
handle.setPointerCapture(event.pointerId);
});
handle.addEventListener("pointermove", (event: PointerEvent) => {
if (!active) return;
const minPx = minWidths[active.key] || 180;
pendingWidth = Math.max(
minPx,
Math.round(active.startWidth + (event.clientX - active.startX)),
);
if (rafId) return;
rafId = requestAnimationFrame(() => {
active!.col.style.width = pendingWidth + "px";
rafId = 0;
});
});
const finish = () => {
if (!active) return;
active = null;
document.body.classList.remove("is-resizing");
persist();
};
handle.addEventListener("pointerup", finish);
handle.addEventListener("pointercancel", finish);
handle.addEventListener("dblclick", (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const key = handle.getAttribute("data-col");
const col = key ? colByKey[key] : null;
const px = key ? defaultWidths[key] : null;
if (!key || !col || !px) return;
col.style.width = px + "px";
persist();
});
});
}
const EMAIL_DELETE_CONFIRM_LABEL = "Confirm delete";
const EMAIL_DELETE_CONFIRM_TIMEOUT_MS = 4000;
function resetEmailDeleteButton(buttonEl: HTMLButtonElement): void {
if (!buttonEl) return;
buttonEl.classList.remove("is-confirming");
buttonEl.removeAttribute("data-confirming");
buttonEl.disabled = false;
buttonEl.textContent = buttonEl.dataset.originalLabel || "Delete";
}
function setEmailButtonLoading(
buttonEl: HTMLButtonElement | null,
loading: boolean,
label?: string,
): void {
if (!buttonEl) return;
if (loading) {
if (!buttonEl.dataset.originalLabel)
buttonEl.dataset.originalLabel = (buttonEl.textContent || "").trim();
buttonEl.classList.add("is-loading");
buttonEl.disabled = true;
buttonEl.textContent = "";
const spinner = document.createElement("span");
spinner.className = "spinner";
spinner.setAttribute("aria-hidden", "true");
buttonEl.appendChild(spinner);
buttonEl.appendChild(
document.createTextNode(" " + (label || "Working...")),
);
return;
}
buttonEl.classList.remove("is-loading");
buttonEl.textContent =
buttonEl.dataset.originalLabel || (buttonEl.textContent || "").trim();
}
function animateEmailRowRemoval(
row: HTMLElement | null,
onDone?: () => void,
): void {
if (!row) {
if (onDone) onDone();
return;
}
row.classList.add("is-removing");
window.setTimeout(() => {
row.remove();
if (onDone) onDone();
}, 240);
}
async function deleteEmailRequest(
emailKey: string,
feedId: string,
): Promise<unknown> {
const res = await fetch(
"/admin/emails/" +
encodeURIComponent(emailKey) +
"/delete?feedId=" +
encodeURIComponent(feedId),
{
method: "POST",
headers: { Accept: "application/json" },
credentials: "same-origin",
},
);
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok)
throw new Error(
data && data.error
? String(data.error)
: "Request failed (" + res.status + ")",
);
return data;
}
function refreshEmailRowCache(): void {
EMAIL_ROWS = Array.from(document.querySelectorAll<HTMLElement>(".email-row"));
EMAIL_CHECKBOXES = Array.from(
document.querySelectorAll<HTMLInputElement>(".email-select"),
);
if (EMAIL_TOTAL_COUNT_EL)
EMAIL_TOTAL_COUNT_EL.textContent = String(EMAIL_ROWS.length);
updateEmailMatchCount();
updateEmailSelectionState();
}
interface ToastHandle {
update?: (msg: string, opts?: Record<string, unknown>) => void;
dismiss?: () => void;
}
declare global {
interface Window {
showToast?: (msg: string, opts?: Record<string, unknown>) => ToastHandle;
parseJsonResponseOrThrow?: (
res: Response,
opts?: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
}
}
function setupEmailDeleteButtons(): void {
Array.from(
document.querySelectorAll<HTMLButtonElement>(
'button[data-delete-kind="email"]',
),
).forEach((button) => {
if (button.dataset.deleteReady === "true") return;
button.dataset.deleteReady = "true";
button.dataset.originalLabel =
(button.textContent || "").trim() || "Delete";
let confirming = false;
let confirmTimer = 0;
let inFlight = false;
const startConfirm = () => {
confirming = true;
button.classList.add("is-confirming");
button.setAttribute("data-confirming", "true");
button.textContent = EMAIL_DELETE_CONFIRM_LABEL;
if (confirmTimer) window.clearTimeout(confirmTimer);
confirmTimer = window.setTimeout(() => {
confirming = false;
resetEmailDeleteButton(button);
}, EMAIL_DELETE_CONFIRM_TIMEOUT_MS);
};
button.addEventListener("click", async (event: MouseEvent) => {
event.preventDefault();
if (inFlight) return;
if (!confirming) {
startConfirm();
return;
}
if (confirmTimer) window.clearTimeout(confirmTimer);
inFlight = true;
setEmailButtonLoading(button, true, "Deleting...");
const toast = window.showToast
? window.showToast("Deleting email...", {
type: "info",
loading: true,
duration: 0,
})
: null;
const emailKey = button.getAttribute("data-email-key") || "";
const feedId = button.getAttribute("data-feed-id") || EMAIL_FEED_ID;
const row = button.closest<HTMLElement>(".email-row");
try {
await deleteEmailRequest(emailKey, feedId);
if (toast && toast.update)
toast.update("Email deleted.", {
type: "success",
loading: false,
duration: 3200,
});
else if (window.showToast)
window.showToast("Email deleted.", { type: "success" });
animateEmailRowRemoval(row, () => refreshEmailRowCache());
} catch (error) {
const msg =
"Delete failed: " +
(error instanceof Error && error.message
? error.message
: "Unknown error");
if (toast && toast.update)
toast.update(msg, { type: "error", loading: false });
else if (window.showToast) window.showToast(msg, { type: "error" });
setEmailButtonLoading(button, false);
confirming = false;
resetEmailDeleteButton(button);
} finally {
inFlight = false;
if (!row) {
setEmailButtonLoading(button, false);
confirming = false;
resetEmailDeleteButton(button);
}
}
});
button.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Escape" && confirming && !inFlight) {
confirming = false;
if (confirmTimer) window.clearTimeout(confirmTimer);
resetEmailDeleteButton(button);
}
});
});
}
function updateEmailSelectionState(): void {
if (!EMAIL_CHECKBOXES.length) return;
const selected = EMAIL_CHECKBOXES.filter((c) => c.checked);
if (EMAIL_SELECTED_COUNT_EL)
EMAIL_SELECTED_COUNT_EL.textContent = selected.length + " selected";
if (EMAIL_BULK_DELETE_BUTTON_EL)
EMAIL_BULK_DELETE_BUTTON_EL.disabled = selected.length === 0;
if (EMAIL_SELECT_ALL_EL) {
const visible = EMAIL_CHECKBOXES.filter(
(c) => !(c.closest("tr") as HTMLElement | null)?.hidden,
);
EMAIL_SELECT_ALL_EL.checked =
visible.length > 0 && visible.every((c) => c.checked);
}
}
function toggleAllEmails(checked: boolean): void {
EMAIL_CHECKBOXES.forEach((c) => {
if (!(c.closest("tr") as HTMLElement | null)?.hidden) c.checked = checked;
});
updateEmailSelectionState();
}
function selectMatchingEmails(): void {
EMAIL_CHECKBOXES.forEach((c) => {
if (!(c.closest("tr") as HTMLElement | null)?.hidden) c.checked = true;
});
updateEmailSelectionState();
}
function clearEmailSelection(): void {
EMAIL_CHECKBOXES.forEach((c) => {
c.checked = false;
});
updateEmailSelectionState();
}
function filterEmailRows(): void {
const query = (
(document.getElementById("email-search") as HTMLInputElement | null)
?.value || ""
)
.toLowerCase()
.trim();
EMAIL_ROWS.forEach((row) => {
row.hidden =
!!query && !(row.getAttribute("data-search") || "").includes(query);
});
updateEmailMatchCount();
updateEmailSelectionState();
}
function confirmBulkEmailDelete(): boolean {
const selected = EMAIL_CHECKBOXES.filter((c) => c.checked).length;
if (selected === 0) return false;
const query = (
(document.getElementById("email-search") as HTMLInputElement | null)
?.value || ""
).trim();
const extra =
selected >= 200 && !query
? "\n\nThis is a large delete. Tip: use Search to narrow down spam first."
: "";
return confirm("Delete " + selected + " selected email(s)?" + extra);
}
function removeEmailRowsByKey(emailKeys: string[]): void {
const toRemove = new Set((emailKeys || []).map((v) => String(v)));
if (!toRemove.size) return;
EMAIL_ROWS.forEach((row) => {
const cb = row.querySelector<HTMLInputElement>("input.email-select");
if (cb && toRemove.has(cb.value)) row.remove();
});
EMAIL_ROWS = Array.from(document.querySelectorAll<HTMLElement>(".email-row"));
EMAIL_CHECKBOXES = Array.from(
document.querySelectorAll<HTMLInputElement>(".email-select"),
);
if (EMAIL_TOTAL_COUNT_EL)
EMAIL_TOTAL_COUNT_EL.textContent = String(EMAIL_ROWS.length);
}
function onBulkEmailDeleteSubmit(event: Event): boolean {
if (event && event.preventDefault) event.preventDefault();
void bulkDeleteSelectedEmails();
return false;
}
async function bulkDeleteSelectedEmails(): Promise<void> {
if (EMAIL_BULK_DELETE_IN_PROGRESS) return;
const selectedKeys = EMAIL_CHECKBOXES.filter((c) => c.checked).map(
(c) => c.value,
);
if (!selectedKeys.length) {
if (window.showToast)
window.showToast("No emails selected.", { type: "info" });
return;
}
if (!confirmBulkEmailDelete()) return;
EMAIL_BULK_DELETE_IN_PROGRESS = true;
setEmailButtonLoading(EMAIL_BULK_DELETE_BUTTON_EL, true, "Deleting...");
const toast = window.showToast
? window.showToast("Deleting " + selectedKeys.length + " email(s)...", {
type: "info",
loading: true,
duration: 0,
})
: null;
const batchSize = 50;
let deletedTotal = 0;
const failed: string[] = [];
try {
const url =
"/admin/feeds/" +
encodeURIComponent(EMAIL_FEED_ID) +
"/emails/bulk-delete";
for (let i = 0; i < selectedKeys.length; i += batchSize) {
const batch = selectedKeys.slice(i, i + batchSize);
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
credentials: "same-origin",
body: JSON.stringify({ emailKeys: batch }),
});
let data: Record<string, unknown> = {};
if (window.parseJsonResponseOrThrow) {
data = await window.parseJsonResponseOrThrow(res, {
prefix: "Bulk email delete failed",
});
} else {
data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok)
throw new Error(
data && data.error
? String(data.error)
: "Bulk email delete failed (HTTP " + res.status + ")",
);
}
const deletedKeys = Array.isArray(data.deletedEmailKeys)
? (data.deletedEmailKeys as string[])
: batch;
removeEmailRowsByKey(deletedKeys);
deletedTotal += deletedKeys.length;
failed.push(
...(Array.isArray(data.failedEmailKeys)
? (data.failedEmailKeys as string[])
: []),
);
if (toast && toast.update)
toast.update(
"Deleting... (" +
Math.min(i + batch.length, selectedKeys.length) +
" of " +
selectedKeys.length +
")",
{ type: "info" },
);
updateEmailMatchCount();
updateEmailSelectionState();
}
if (toast && toast.dismiss) toast.dismiss();
if (failed.length > 0) {
if (window.showToast)
window.showToast(
"Deleted " +
deletedTotal +
" email(s). " +
failed.length +
" failed (still visible).",
{ type: "error" },
);
} else {
if (window.showToast)
window.showToast("Deleted " + deletedTotal + " email(s).", {
type: "success",
});
}
} catch (error) {
if (toast && toast.dismiss) toast.dismiss();
if (window.showToast)
window.showToast(
error instanceof Error && error.message
? error.message
: "Bulk email delete failed.",
{ type: "error" },
);
} finally {
EMAIL_BULK_DELETE_IN_PROGRESS = false;
setEmailButtonLoading(EMAIL_BULK_DELETE_BUTTON_EL, false);
updateEmailSelectionState();
}
}
// Expose functions needed by inline HTML event handlers
(window as unknown as Record<string, unknown>).scheduleEmailFilter =
scheduleEmailFilter;
(window as unknown as Record<string, unknown>).toggleAllEmails =
toggleAllEmails;
(window as unknown as Record<string, unknown>).selectMatchingEmails =
selectMatchingEmails;
(window as unknown as Record<string, unknown>).clearEmailSelection =
clearEmailSelection;
(window as unknown as Record<string, unknown>).onBulkEmailDeleteSubmit =
onBulkEmailDeleteSubmit;
document.addEventListener("DOMContentLoaded", () => {
initEmailUI();
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"lib": ["ES2021", "DOM"],
"types": [],
"noEmit": true
},
"include": ["./**/*"],
"exclude": []
}
+1 -1
View File
@@ -61,4 +61,4 @@ export const clipboardScripts = `
form.submit(); form.submit();
} }
} }
`; `;
+1 -1
View File
@@ -70,4 +70,4 @@ export const initScripts = `
// Run setup when DOM is fully loaded // Run setup when DOM is fully loaded
document.addEventListener('DOMContentLoaded', initInteractive); document.addEventListener('DOMContentLoaded', initInteractive);
`; `;
+974
View File
@@ -0,0 +1,974 @@
/* Card */
.card {
background-color: var(--color-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
border: 1px solid var(--color-border);
transition: border-color var(--transition-normal);
}
.card h2 {
margin-top: 0;
}
/* Feed header styling */
.feed-header {
margin-bottom: var(--spacing-md);
}
.feed-title {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-xs);
color: var(--color-text-primary);
}
.feed-description {
font-size: var(--font-size-md);
color: var(--color-text-secondary);
margin-top: 0;
margin-bottom: var(--spacing-sm);
}
.feed-description.empty {
color: var(--color-text-tertiary);
font-style: italic;
}
/* In-place editing styles */
input.feed-title-edit,
.feed-title-edit {
width: 100%;
font-size: var(--font-size-xl) !important;
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-xs);
padding: 4px 6px !important;
background-color: transparent !important;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
border-radius: var(--radius-sm);
}
textarea.feed-description-edit,
.feed-description-edit {
width: 100%;
font-size: var(--font-size-md) !important;
margin-bottom: var(--spacing-sm);
padding: 4px 6px !important;
min-height: 60px;
background-color: transparent !important;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-family: var(--font-family);
border-radius: var(--radius-sm);
}
.hidden {
display: none !important;
}
/* Success button styles */
.button-success {
background-color: var(--color-primary);
color: var(--color-text-on-primary);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.5s ease;
}
.button-success:hover,
.button-success:focus {
opacity: 0.88;
}
.button-success.saved {
background-color: var(--color-success);
transition: all 0.5s ease;
}
.button-success.saved:hover {
background-color: var(--color-success);
}
/* Button container */
.feed-buttons {
display: flex;
justify-content: space-between;
gap: var(--spacing-sm);
}
.feed-buttons-left {
display: flex;
gap: var(--spacing-sm);
}
.feed-buttons-left .button {
width: 140px;
justify-content: center;
box-sizing: border-box;
}
.feed-buttons-left a.button {
width: 140px;
box-sizing: border-box;
text-align: center;
}
.feed-buttons-right {
margin-left: auto;
}
/* Button — primary orange */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
height: 40px;
padding: 0 var(--spacing-lg);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family);
text-decoration: none;
cursor: pointer;
transition:
opacity var(--transition-fast),
transform var(--transition-fast),
box-shadow var(--transition-fast);
white-space: nowrap;
letter-spacing: -0.01em;
background-color: var(--color-primary);
border: 1px solid transparent;
box-shadow: var(--shadow-sm);
color: var(--color-text-on-primary);
}
.button:hover,
.button:focus {
opacity: 0.88;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.button:active {
transform: translateY(0);
opacity: 1;
box-shadow: var(--shadow-sm);
}
.button-secondary {
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
}
@media (prefers-color-scheme: light) {
.button-secondary {
background-color: rgba(0, 0, 0, 0.04);
}
}
.button-secondary:hover,
.button-secondary:focus {
background-color: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.2);
}
@media (prefers-color-scheme: light) {
.button-secondary:hover,
.button-secondary:focus {
background-color: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.16);
}
}
/* Logout button — same orange family */
.button-logout {
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
}
.button-logout:hover,
.button-logout:focus {
color: var(--color-primary);
border-color: var(--color-primary);
}
/* Back button */
.button-back {
display: inline-flex;
align-items: center;
padding-left: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.button-back:before {
content: "←";
margin-right: var(--spacing-sm);
font-size: var(--font-size-lg);
}
.button-danger {
background-color: var(--color-danger);
color: var(--color-text-on-primary);
border-color: transparent;
}
.button-danger:hover,
.button-danger:focus {
background-color: var(--color-danger);
opacity: 0.85;
}
/* Small button */
.button-small {
height: 32px;
padding: 0 var(--spacing-md);
font-size: var(--font-size-xs);
}
/* Form Elements */
.form-group {
margin-bottom: var(--spacing-lg);
}
label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--spacing-xs);
color: var(--color-text-secondary);
}
input[type="text"],
input[type="email"],
input[type="search"],
input[type="password"],
textarea {
display: block;
width: 100%;
padding: var(--spacing-md);
font-size: var(--font-size-md);
font-family: var(--font-family);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background-color: rgba(255, 255, 255, 0.04);
color: var(--color-text-primary);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
box-sizing: border-box;
}
@media (prefers-color-scheme: light) {
input[type="text"],
input[type="email"],
input[type="search"],
input[type="password"],
textarea {
background-color: rgba(0, 0, 0, 0.03);
border-color: rgba(0, 0, 0, 0.14);
}
}
input[type="search"] {
-webkit-appearance: none;
appearance: none;
}
input[type="search"]::-webkit-search-decoration,
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-results-button,
input[type="search"]::-webkit-search-results-decoration {
-webkit-appearance: none;
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(246, 130, 31, 0.2);
}
/* Modal */
.modal-bg {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(var(--blur-md));
-webkit-backdrop-filter: blur(var(--blur-md));
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
transition: opacity var(--transition-normal);
}
.modal-bg.visible {
display: flex;
opacity: 1;
}
.modal {
background-color: var(--color-card);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
max-width: 90%;
width: 500px;
box-shadow: var(--shadow-xl);
transform: scale(0.98);
opacity: 0;
transition: all var(--transition-normal);
border: 1px solid var(--color-border);
}
.modal-bg.visible .modal {
transform: scale(1);
opacity: 1;
}
.modal-header {
margin-bottom: var(--spacing-lg);
}
.modal-buttons {
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
margin-top: var(--spacing-xl);
}
/* Toggle/Switch */
.toggle-switch {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-md);
}
.toggle-switch input[type="checkbox"] {
height: 0;
width: 0;
visibility: hidden;
position: absolute;
}
.toggle-switch label {
cursor: pointer;
width: 50px;
height: 28px;
background: var(--color-text-tertiary);
display: block;
border-radius: 100px;
position: relative;
transition: background-color var(--transition-fast);
}
.toggle-switch label:after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 24px;
height: 24px;
background: var(--color-text-on-primary);
border-radius: 90px;
transition: transform var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.toggle-switch input:checked + label {
background: var(--color-primary);
}
.toggle-switch input:checked + label:after {
transform: translateX(22px);
}
/* Email Content Iframe */
.email-iframe-container {
width: 100%;
overflow: hidden;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background-color: #ffffff;
}
.email-iframe {
width: 100%;
height: 500px;
border: none;
background-color: #ffffff;
transition: height 0.3s ease;
}
@media (prefers-color-scheme: dark) {
.email-iframe-container {
background-color: #1c1c1e;
border-color: #2a2a2a;
}
.email-iframe {
background-color: #1c1c1e;
}
}
/* Email Raw View */
.email-raw {
padding: var(--spacing-md);
background-color: #111111;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
overflow: auto;
max-height: 500px;
}
@media (prefers-color-scheme: light) {
.email-raw {
background-color: rgba(240, 240, 245, 0.9);
}
}
.email-raw pre {
margin: 0;
font-family: var(--font-family-mono);
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
}
/* Email Metadata */
.email-meta {
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
}
.email-meta h2 {
margin-top: 0;
margin-bottom: var(--spacing-md);
font-size: var(--font-size-xl);
color: var(--color-text-primary);
}
.email-metadata-grid {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: repeat(4, auto);
gap: var(--spacing-sm);
}
@media (min-width: 640px) {
.email-metadata-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, auto);
}
}
/* Toggle buttons for email view */
.toggle-view {
display: flex;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-md);
}
.toggle-button {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
font-family: var(--font-family);
}
.toggle-button:hover {
color: var(--color-text-primary);
border-color: rgba(255, 255, 255, 0.2);
}
.toggle-button.active {
background-color: var(--color-primary);
color: var(--color-text-on-primary);
border-color: transparent;
}
/* Email content container */
.email-content {
margin-top: var(--spacing-md);
border-radius: var(--radius-md);
overflow: hidden;
}
/* Toolbar */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
flex-wrap: wrap;
margin-bottom: var(--spacing-md);
}
.toolbar-group {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.toolbar-group-fill {
width: 100%;
}
input.search {
min-width: 280px;
flex: 1;
height: 36px;
border-radius: var(--radius-pill);
padding: 0 14px;
padding-left: 38px;
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid var(--color-border);
background-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='%23555555'%20stroke-width='2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Ccircle%20cx='11'%20cy='11'%20r='7'/%3E%3Cpath%20d='M21%2021l-4.3-4.3'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 14px center;
background-size: 16px 16px;
}
@media (prefers-color-scheme: light) {
input.search {
background-color: rgba(0, 0, 0, 0.03);
}
}
input.search::placeholder {
color: var(--color-text-tertiary);
}
.actions-row {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-md);
}
.pill {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border);
background-color: transparent;
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.segmented {
display: inline-flex;
border-radius: var(--radius-pill);
padding: 2px;
border: 1px solid var(--color-border);
background-color: transparent;
box-shadow: var(--shadow-sm);
}
.segmented-item {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 14px;
border-radius: var(--radius-pill);
text-decoration: none;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
user-select: none;
transition: all var(--transition-fast);
}
.segmented-item:hover {
color: var(--color-text-primary);
}
.segmented-item.is-active {
color: var(--color-text-primary);
background-color: var(--color-primary);
color: var(--color-text-on-primary);
}
/* Tables */
.table-wrap {
overflow-x: auto;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
}
table.table {
width: 100%;
border-collapse: collapse;
}
table.table.table-feeds {
min-width: 860px;
table-layout: fixed;
}
table.table.table-emails {
min-width: 760px;
table-layout: fixed;
}
table.table th,
table.table td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: top;
}
/* Resizable headers */
th.th-resizable {
position: relative;
padding-right: 18px;
}
.th-button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
user-select: none;
}
.th-button:hover {
color: var(--color-text-primary);
}
.sort-indicator {
width: 12px;
height: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0.75;
font-size: 12px;
}
.col-resizer {
position: absolute;
top: 0;
right: 0;
width: 12px;
height: 100%;
cursor: col-resize;
touch-action: none;
}
.col-resizer:after {
content: "";
position: absolute;
right: 5px;
top: 24%;
bottom: 24%;
width: 1px;
background: var(--color-border);
opacity: 0.9;
border-radius: 1px;
}
th.th-resizable:hover .col-resizer:after {
background: rgba(255, 255, 255, 0.15);
}
@media (prefers-color-scheme: light) {
th.th-resizable:hover .col-resizer:after {
background: rgba(0, 0, 0, 0.15);
}
}
body.is-resizing {
cursor: col-resize;
user-select: none;
}
table.table thead th {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-text-tertiary);
background-color: rgba(255, 255, 255, 0.02);
}
@media (prefers-color-scheme: light) {
table.table thead th {
background-color: rgba(0, 0, 0, 0.02);
color: var(--color-text-secondary);
}
}
table.table tbody tr:hover {
background-color: rgba(255, 255, 255, 0.03);
}
@media (prefers-color-scheme: light) {
table.table tbody tr:hover {
background-color: rgba(0, 0, 0, 0.02);
}
}
table.table tbody tr:last-child td {
border-bottom: none;
}
table.table code {
font-family: var(--font-family-mono);
font-size: 13px;
}
.truncate {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* Compact copy-to-clipboard for table cells */
.copyable.copyable-inline {
margin-bottom: 0;
padding: 0;
background-color: transparent;
border: none;
}
.copyable.copyable-inline .copyable-content {
padding: 6px 8px;
border-radius: var(--radius-sm);
width: 100%;
}
.copyable.copyable-inline .copyable-value {
margin-right: var(--spacing-xs);
word-break: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.button-delete {
transition:
background-color 180ms ease,
border-color 180ms ease,
color 180ms ease,
transform 180ms ease,
box-shadow 180ms ease;
}
.button-delete.is-confirming {
background-color: rgba(255, 69, 58, 0.22);
border-color: rgba(255, 69, 58, 0.65);
color: #fff;
box-shadow: 0 8px 20px rgba(255, 69, 58, 0.18);
transform: translateY(-1px);
}
@media (prefers-color-scheme: light) {
.button-delete.is-confirming {
background-color: rgba(255, 69, 58, 0.12);
color: var(--color-text-primary);
}
}
.feed-row.is-removing,
.email-row.is-removing {
opacity: 0;
transform: translateY(-6px);
transition:
opacity 200ms ease,
transform 200ms ease;
}
.feed-item.is-removing {
opacity: 0;
transform: translateY(-6px);
overflow: hidden;
transition:
opacity 220ms ease,
transform 220ms ease,
max-height 220ms ease,
margin 220ms ease,
padding 220ms ease;
}
/* Spinner */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.25);
border-top-color: rgba(255, 255, 255, 0.9);
display: inline-block;
animation: spin 0.85s linear infinite;
flex: 0 0 auto;
}
@media (prefers-color-scheme: light) {
.spinner {
border-color: rgba(0, 0, 0, 0.14);
border-top-color: rgba(0, 0, 0, 0.5);
}
}
.button.is-loading {
pointer-events: none;
}
.button .spinner {
margin-right: 8px;
}
/* Toasts */
.toast-stack {
position: fixed;
top: 18px;
right: 18px;
width: min(360px, calc(100vw - 36px));
display: flex;
flex-direction: column;
gap: 10px;
z-index: 2000;
}
.toast {
opacity: 0;
transform: translateY(-8px);
transition:
opacity 180ms ease,
transform 180ms ease;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
background-color: #1a1a1a;
backdrop-filter: blur(var(--blur-md));
-webkit-backdrop-filter: blur(var(--blur-md));
box-shadow: var(--shadow-xl);
padding: 12px 12px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
color: var(--color-text-primary);
}
@media (prefers-color-scheme: light) {
.toast {
background-color: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-xl);
}
}
.toast.visible {
opacity: 1;
transform: translateY(0);
}
.toast-body {
display: flex;
gap: 10px;
align-items: flex-start;
flex: 1;
min-width: 0;
}
.toast-text {
font-size: 14px;
line-height: 1.35;
color: var(--color-text-primary);
word-break: break-word;
flex: 1;
min-width: 0;
user-select: text;
}
.toast-close {
appearance: none;
border: none;
background: transparent;
color: var(--color-text-tertiary);
font-size: 18px;
line-height: 1;
padding: 2px 6px;
cursor: pointer;
border-radius: var(--radius-sm);
flex: 0 0 auto;
}
.toast-close:hover {
background-color: rgba(255, 255, 255, 0.06);
color: var(--color-text-secondary);
}
@media (prefers-color-scheme: light) {
.toast-close:hover {
background-color: rgba(0, 0, 0, 0.06);
}
}
.toast-info {
border-color: rgba(246, 130, 31, 0.35);
}
.toast-success {
border-color: rgba(48, 209, 88, 0.35);
}
.toast-error {
border-color: rgba(255, 69, 58, 0.35);
}
/* Validation badges */
.feed-validate {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.feed-validate img {
display: block;
}
/* Feed and Email Lists */
.feed-list,
.email-list {
list-style: none;
padding: 0;
margin: 0;
}
File diff suppressed because it is too large. Load diff
-7
View File
@@ -1,7 +0,0 @@
// This file is kept for backwards compatibility
// It re-exports the new modular design system
import { designSystem } from './index';
import { interactiveScripts, authHelpers } from '../scripts/index';
export { designSystem, interactiveScripts, authHelpers };
-20
View File
@@ -1,20 +0,0 @@
// Main style exports file
// Combines all style components and re-exports them for easy imports
import { variables, lightModeTheme, fontImport } from './variables';
import { layoutStyles } from './layout';
import { componentStyles } from './components';
import { utilityStyles } from './utilities';
// Combine all style components into a single CSS string
export const designSystem = `
${variables}
${lightModeTheme}
${fontImport}
${layoutStyles}
${componentStyles}
${utilityStyles}
`;
// Re-export everything for modular usage if needed
export { variables, lightModeTheme, fontImport, layoutStyles, componentStyles, utilityStyles };
+248
View File
@@ -0,0 +1,248 @@
/* Base Page Layout */
.page {
font-family: var(--font-family);
line-height: 1.5;
background-color: var(--color-background);
color: var(--color-text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
transition: background-color var(--transition-normal);
overflow-x: hidden;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Subtle orange glow matching landing page hero */
background-image: radial-gradient(
900px circle at 50% 0%,
rgba(246, 130, 31, 0.08),
transparent 60%
);
background-attachment: fixed;
}
@media (prefers-color-scheme: light) {
.page {
background-image: radial-gradient(
900px circle at 50% 0%,
rgba(246, 130, 31, 0.06),
transparent 60%
);
}
}
/* Main Container — unified width for list and table views */
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: var(--spacing-xl);
box-sizing: border-box;
}
/* Keep for compatibility but unused on dashboard */
.container-wide {
max-width: 1400px;
}
/* Header Styles */
.header {
margin-bottom: var(--spacing-xl);
}
.header h1 {
font-size: var(--font-size-xxl);
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-xs);
line-height: 1.2;
background: var(--gradient-title);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
}
.header p {
font-size: var(--font-size-lg);
color: var(--color-text-secondary);
margin-top: 0;
max-width: 80%;
}
/* Authentication Screen Layout */
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-background);
}
.auth-card {
width: 100%;
max-width: 400px;
padding: var(--spacing-xl);
background-color: var(--color-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
border: 1px solid var(--color-border);
transition: all var(--transition-normal);
}
.auth-logo {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.auth-title {
font-size: var(--font-size-xl);
text-align: center;
margin-bottom: var(--spacing-xl);
}
.auth-error {
color: var(--color-danger);
background-color: rgba(255, 59, 48, 0.1);
padding: var(--spacing-md);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-lg);
text-align: center;
font-weight: var(--font-weight-medium);
}
.auth-form {
margin-bottom: var(--spacing-lg);
}
.auth-button {
width: 100%;
margin-top: var(--spacing-lg);
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.container {
padding: var(--spacing-md);
}
.header {
padding: var(--spacing-md) 0;
margin-bottom: var(--spacing-lg);
}
.header h1 {
font-size: var(--font-size-xl);
}
}
.header-with-actions {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-xl);
}
.header-title {
flex: 1;
}
.header h1,
.header-title h1 {
font-size: var(--font-size-xxl);
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-xs);
line-height: 1.2;
color: var(--color-text-primary);
background: var(--gradient-title);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
}
.header p,
.header-title p {
font-size: var(--font-size-lg);
color: var(--color-text-secondary);
margin-top: 0;
max-width: 80%;
}
.header-actions {
display: flex;
align-items: center;
margin-top: var(--spacing-md);
}
.feed-title {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-md);
color: var(--color-text-primary);
}
/* Site Header */
.site-header {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-xl);
border-bottom: 1px solid var(--color-border);
}
.site-header-logo {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-primary);
text-decoration: none;
letter-spacing: -0.01em;
transition: opacity var(--transition-fast);
}
.site-header-logo:hover {
opacity: 0.8;
}
.site-header-label {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
padding: 2px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
}
/* Site Footer */
.site-footer {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-lg) var(--spacing-xl);
margin-top: auto;
border-top: 1px solid var(--color-border);
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
}
.site-footer a {
color: var(--color-text-tertiary);
text-decoration: none;
transition: color var(--transition-fast);
}
.site-footer a:hover {
color: var(--color-text-secondary);
}
.site-footer-sep {
opacity: 0.4;
}
.site-footer-sponsor {
color: var(--color-text-tertiary) !important;
}
.site-footer-sponsor:hover {
color: #db61a2 !important;
}
-180
View File
@@ -1,180 +0,0 @@
// Layout styles for the application
// Contains styles for containers, headers, page structure
export const layoutStyles = `
/* Base Page Layout */
.page {
font-family: var(--font-family);
line-height: 1.5;
background-color: var(--color-background);
color: var(--color-text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
transition: background-color var(--transition-normal);
overflow-x: hidden;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Liquid-glass-ish background (subtle, non-distracting) */
background-image:
radial-gradient(1200px circle at 20% 10%, rgba(10, 132, 255, 0.18), transparent 55%),
radial-gradient(900px circle at 80% 20%, rgba(94, 92, 230, 0.14), transparent 60%),
radial-gradient(700px circle at 50% 100%, rgba(48, 209, 88, 0.10), transparent 60%),
linear-gradient(135deg, rgba(255, 255, 255, 0.03) 0%, rgba(0, 0, 0, 0.03) 100%);
background-attachment: fixed;
}
/* Main Container */
.container {
width: 100%;
max-width: 980px;
margin: 0 auto;
padding: var(--spacing-xl);
box-sizing: border-box;
}
/* Wider layout for data-dense pages (tables) */
.container-wide {
max-width: 1440px;
}
/* Header Styles */
.header {
margin-bottom: var(--spacing-xl);
}
.header h1 {
font-size: var(--font-size-xxl);
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-xs);
line-height: 1.2;
background: var(--gradient-title);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
}
.header p {
font-size: var(--font-size-lg);
color: var(--color-text-secondary);
margin-top: 0;
max-width: 80%;
}
/* Authentication Screen Layout */
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-background);
}
.auth-card {
width: 100%;
max-width: 400px;
padding: var(--spacing-xl);
background-color: var(--color-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
border: 1px solid var(--color-border);
transition: all var(--transition-normal);
backdrop-filter: blur(var(--blur-md));
-webkit-backdrop-filter: blur(var(--blur-md));
}
.auth-logo {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.auth-title {
font-size: var(--font-size-xl);
text-align: center;
margin-bottom: var(--spacing-xl);
}
.auth-error {
color: var(--color-danger);
background-color: rgba(255, 59, 48, 0.1);
padding: var(--spacing-md);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-lg);
text-align: center;
font-weight: var(--font-weight-medium);
backdrop-filter: blur(var(--blur-sm));
-webkit-backdrop-filter: blur(var(--blur-sm));
}
.auth-form {
margin-bottom: var(--spacing-lg);
}
.auth-button {
width: 100%;
margin-top: var(--spacing-lg);
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.container {
padding: var(--spacing-md);
}
.header {
padding: var(--spacing-md) 0;
margin-bottom: var(--spacing-lg);
}
.header h1 {
font-size: var(--font-size-xl);
}
}
.header-with-actions {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-xl);
}
.header-title {
flex: 1;
}
.header h1, .header-title h1 {
font-size: var(--font-size-xxl);
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-xs);
line-height: 1.2;
color: var(--color-text-primary); /* Fallback color */
background: var(--gradient-title);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
}
.header p, .header-title p {
font-size: var(--font-size-lg);
color: var(--color-text-secondary);
margin-top: 0;
max-width: 80%;
}
.header-actions {
display: flex;
align-items: center;
margin-top: var(--spacing-md);
}
.feed-title {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-md);
color: var(--color-text-primary);
}
`;
+229
View File
@@ -0,0 +1,229 @@
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
.muted {
color: var(--color-text-secondary);
}
@media (prefers-reduced-motion: reduce) {
.fade-in {
animation: none;
}
* {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
/* Copyable content styling */
.copyable {
position: relative;
display: flex;
align-items: center;
margin-bottom: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
background-color: rgba(60, 60, 67, 0.1);
border-radius: var(--radius-md);
transition: background-color var(--transition-fast);
}
/* When inside a grid, ensure proper fit */
.email-metadata-grid .copyable {
margin-bottom: 0; /* Remove bottom margin inside grid */
}
.copyable-label {
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
margin-right: var(--spacing-sm);
user-select: none;
white-space: nowrap;
}
.copyable-content {
display: inline-flex;
align-items: center;
cursor: pointer;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
transition: background-color var(--transition-fast);
width: fit-content;
overflow: hidden; /* Prevent overflow in small containers */
flex: 1; /* Take remaining space */
}
.copyable-content:hover {
background-color: rgba(60, 60, 67, 0.2);
}
.copyable-value {
word-break: break-all;
margin-right: var(--spacing-xs);
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.copy-icon-container {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 20px;
height: 20px;
}
.copy-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* Simplified icon states and transitions */
.copy-icon-original {
opacity: 0.6;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
/* Only apply hover effect when not in copied state */
.copyable-content:hover:not(.copied) .copy-icon-original {
opacity: 1;
}
.copy-icon-success {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
color: var(--color-success);
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
/* When copied, hide original and show success icon with a smooth transition */
.copyable-content.copied .copy-icon-original {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
.copyable-content.copied .copy-icon-success {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
/* ── Sender filter dropdown ── */
.sender-filter-dropdown {
position: relative;
flex-shrink: 0;
margin-left: var(--spacing-xs);
display: flex;
flex-direction: column;
align-items: flex-end;
}
.sender-filter-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
font-size: 18px;
line-height: 1;
cursor: pointer;
transition:
background-color var(--transition-fast),
color var(--transition-fast);
}
.sender-filter-btn:hover {
background-color: rgba(255, 255, 255, 0.08);
color: var(--color-text-primary);
}
.sender-filter-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 4px);
min-width: 220px;
background-color: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
z-index: 100;
overflow: hidden;
}
.sender-filter-dropdown[data-open] .sender-filter-menu {
display: block;
}
.sender-filter-item {
display: block;
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
background: none;
border: none;
font-size: var(--font-size-sm);
cursor: pointer;
transition: background-color var(--transition-fast);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sender-filter-allow {
color: var(--color-success);
}
.sender-filter-allow:hover {
background-color: rgba(48, 209, 88, 0.12);
}
.sender-filter-block {
color: var(--color-danger);
}
.sender-filter-block:hover {
background-color: rgba(255, 69, 58, 0.12);
}
/* Divider between allow and block groups */
.sender-filter-block:first-of-type {
border-top: 1px solid var(--color-border);
}
.sender-filter-feedback {
font-size: var(--font-size-xs);
white-space: nowrap;
min-height: 16px;
}
.sender-filter-feedback-ok {
color: var(--color-success);
}
.sender-filter-feedback-error {
color: var(--color-danger);
}
-123
View File
@@ -1,123 +0,0 @@
// Utility styles for the application
// Contains styles for utility classes like copyable text, animations, etc.
export const utilityStyles = `
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
.muted {
color: var(--color-text-secondary);
}
@media (prefers-reduced-motion: reduce) {
.fade-in {
animation: none;
}
* {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
/* Copyable content styling */
.copyable {
position: relative;
display: flex;
align-items: center;
margin-bottom: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
background-color: rgba(60, 60, 67, 0.1);
border-radius: var(--radius-md);
transition: background-color var(--transition-fast);
}
/* When inside a grid, ensure proper fit */
.email-metadata-grid .copyable {
margin-bottom: 0; /* Remove bottom margin inside grid */
}
.copyable-label {
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
margin-right: var(--spacing-sm);
user-select: none;
white-space: nowrap;
}
.copyable-content {
display: inline-flex;
align-items: center;
cursor: pointer;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
transition: background-color var(--transition-fast);
width: fit-content;
overflow: hidden; /* Prevent overflow in small containers */
flex: 1; /* Take remaining space */
}
.copyable-content:hover {
background-color: rgba(60, 60, 67, 0.2);
}
.copyable-value {
word-break: break-all;
margin-right: var(--spacing-xs);
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.copy-icon-container {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 20px;
height: 20px;
}
.copy-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* Simplified icon states and transitions */
.copy-icon-original {
opacity: 0.6;
transition: opacity 0.2s ease, transform 0.2s ease;
}
/* Only apply hover effect when not in copied state */
.copyable-content:hover:not(.copied) .copy-icon-original {
opacity: 1;
}
.copy-icon-success {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
color: var(--color-success);
transition: opacity 0.2s ease, transform 0.2s ease;
}
/* When copied, hide original and show success icon with a smooth transition */
.copyable-content.copied .copy-icon-original {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
.copyable-content.copied .copy-icon-success {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
`;
+98
View File
@@ -0,0 +1,98 @@
:root {
/* Typography — Inter matches the landing page style */
--font-family:
"Inter", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial,
sans-serif;
--font-family-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
color-scheme: light dark;
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-md: 16px;
--font-size-lg: 20px;
--font-size-xl: 24px;
--font-size-xxl: 32px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Colors — Dark Mode (Default) — aligned with kill-the-news landing page */
--color-primary: #f6821f;
--color-primary-dark: #ffa050;
--color-secondary: #f0b429;
--color-success: #30d158;
--color-warning: #ff9f0a;
--color-danger: #ff453a;
--color-danger-dark: #ff6961;
--color-background: #0a0a0a;
--color-card: #111111;
--color-border: rgba(255, 255, 255, 0.1);
--color-text-primary: #f0f0f0;
--color-text-secondary: #888888;
--color-text-tertiary: #555555;
--color-text-on-primary: #ffffff;
--color-logout: rgba(246, 130, 31, 0.85);
/* Gradients */
--gradient-title: linear-gradient(135deg, #f6821f, #f0b429);
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.5);
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-xxl: 48px;
/* Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 12px;
--radius-pill: 9999px;
/* Animation */
--transition-fast: 0.15s cubic-bezier(0.16, 1, 0.3, 1);
--transition-normal: 0.2s cubic-bezier(0.16, 1, 0.3, 1);
--transition-slow: 0.3s cubic-bezier(0.16, 1, 0.3, 1);
/* Blur — only for modals and toasts */
--blur-sm: 8px;
--blur-md: 12px;
--blur-lg: 20px;
}
/* Light Mode */
@media (prefers-color-scheme: light) {
:root {
--color-primary: #e07010;
--color-primary-dark: #c05800;
--color-secondary: #d49800;
--color-success: #34c759;
--color-warning: #ff9500;
--color-danger: #ff3b30;
--color-danger-dark: #d70015;
--color-background: #f5f5f7;
--color-card: #ffffff;
--color-border: rgba(0, 0, 0, 0.1);
--color-text-primary: #111111;
--color-text-secondary: #555555;
--color-text-tertiary: #999999;
--color-text-on-primary: #ffffff;
--color-logout: rgba(224, 112, 16, 0.9);
--gradient-title: linear-gradient(135deg, #e07010, #d49800);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.12);
}
}
-109
View File
@@ -1,109 +0,0 @@
// Design system variables for the application
// Contains CSS variables for typography, colors, spacing, radius, animations, etc.
export const variables = `
:root {
/* Typography - Prefer system UI fonts (Apple HIG-ish) */
--font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Helvetica Neue", Arial, sans-serif;
--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
color-scheme: light dark;
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-md: 16px;
--font-size-lg: 20px;
--font-size-xl: 24px;
--font-size-xxl: 32px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Colors - Dark Mode (Default) */
--color-primary: #0a84ff;
--color-primary-dark: #409cff;
--color-secondary: #5e5ce6;
--color-success: #30d158;
--color-warning: #ff9f0a;
--color-danger: #ff453a;
--color-danger-dark: #ff6961;
--color-background: rgba(28, 28, 30, 0.95); /* Semi-transparent for glass effect */
--color-card: rgba(44, 44, 46, 0.8); /* Semi-transparent for glass effect */
--color-border: rgba(255, 255, 255, 0.08);
--color-text-primary: #ffffff;
--color-text-secondary: #ebebf5;
--color-text-tertiary: #8e8e93;
--color-text-on-primary: #ffffff;
--color-logout: rgba(255, 159, 10, 0.8); /* Orange-tinted for logout */
/* Gradients */
--gradient-title: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
/* Shadows for dark mode */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.2);
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-xxl: 48px;
/* Radius */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-pill: 9999px;
/* Animation - Subtle */
--transition-fast: 0.15s cubic-bezier(0.16, 1, 0.3, 1);
--transition-normal: 0.2s cubic-bezier(0.16, 1, 0.3, 1);
--transition-slow: 0.3s cubic-bezier(0.16, 1, 0.3, 1);
/* Blur for glass effect */
--blur-sm: 8px;
--blur-md: 12px;
--blur-lg: 20px;
}
`;
// Light mode variables
export const lightModeTheme = `
/* Light Mode Support - Based on device preference */
@media (prefers-color-scheme: light) {
:root {
--color-primary: #0070f3;
--color-primary-dark: #0051a8;
--color-secondary: #5e5ce6;
--color-success: #34c759;
--color-warning: #ff9500;
--color-danger: #ff3b30;
--color-danger-dark: #d70015;
--color-background: rgba(245, 245, 247, 0.9); /* Semi-transparent for glass effect */
--color-card: rgba(255, 255, 255, 0.8); /* Semi-transparent for glass effect */
--color-border: rgba(0, 0, 0, 0.06);
--color-text-primary: #000000;
--color-text-secondary: #666666;
--color-text-tertiary: #999999;
--color-text-on-primary: #ffffff;
--color-logout: rgba(255, 149, 0, 0.8); /* Orange-tinted for logout */
/* Reset shadows for light mode */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.03);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.06);
--shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.08);
/* Update gradient for light mode */
--gradient-title: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
}
}
`;
// Inter font import
export const fontImport = `
/* No external font imports. */
`;
+110 -34
View File
@@ -1,26 +1,10 @@
import { beforeAll, afterAll, afterEach } from "vitest"; import { beforeAll, afterAll, afterEach } from "vitest";
import { setupServer } from "msw/node"; import { setupServer } from "msw/node";
/** // Minimal Node.js built-ins used only in this test setup file.
* Mock implementation of Cloudflare Workers runtime environment // Declared locally to avoid pulling in the full @types/node package,
* Based on: https://developers.cloudflare.com/workers/testing/ // which would conflict with @cloudflare/workers-types globals.
*/ declare function require(id: string): any;
// Define Cloudflare Workers runtime globals
declare global {
// CF Worker specific globals
var caches: CacheStorage;
var crypto: Crypto;
var Response: typeof Response;
var Request: typeof Request;
var URLSearchParams: typeof URLSearchParams;
var URL: typeof URL;
var Headers: typeof Headers;
var FormData: typeof FormData;
var Blob: typeof Blob;
var atob: (data: string) => string;
var btoa: (data: string) => string;
}
/** /**
* Mock KV namespace implementation * Mock KV namespace implementation
@@ -87,7 +71,7 @@ class MockCache implements Cache {
async match( async match(
request: RequestInfo, request: RequestInfo,
options?: CacheQueryOptions, _options?: CacheQueryOptions,
): Promise<Response | undefined> { ): Promise<Response | undefined> {
const key = request instanceof Request ? request.url : request; const key = request instanceof Request ? request.url : request;
const response = this.store.get(key); const response = this.store.get(key);
@@ -96,7 +80,7 @@ class MockCache implements Cache {
async delete( async delete(
request: RequestInfo, request: RequestInfo,
options?: CacheQueryOptions, _options?: CacheQueryOptions,
): Promise<boolean> { ): Promise<boolean> {
const key = request instanceof Request ? request.url : request; const key = request instanceof Request ? request.url : request;
return this.store.delete(key); return this.store.delete(key);
@@ -122,36 +106,39 @@ beforeAll(() => {
// Setup MSW server // Setup MSW server
server.listen({ onUnhandledRequest: "error" }); server.listen({ onUnhandledRequest: "error" });
// Type-safe access to Node's global object for setting Workers-like globals in tests
const g = globalThis as any;
// Mock Cloudflare Workers runtime globals // Mock Cloudflare Workers runtime globals
global.caches = { g.caches = {
default: new MockCache(), default: new MockCache(),
open: async () => new MockCache(), open: async () => new MockCache(),
} as unknown as CacheStorage; } as unknown as CacheStorage;
// Mock crypto for generating random values // Mock crypto for generating random values
if (!global.crypto) { if (!g.crypto) {
global.crypto = require("crypto").webcrypto; g.crypto = require("crypto").webcrypto;
} }
// Ensure other required globals are available // Ensure other required globals are available
if (!global.FormData) { if (!g.FormData) {
const { FormData } = require("undici"); const { FormData } = require("undici");
global.FormData = FormData; g.FormData = FormData;
} }
if (!global.Headers) { if (!g.Headers) {
const { Headers } = require("undici"); const { Headers } = require("undici");
global.Headers = Headers; g.Headers = Headers;
} }
if (!global.Request) { if (!g.Request) {
const { Request } = require("undici"); const { Request } = require("undici");
global.Request = Request; g.Request = Request;
} }
if (!global.Response) { if (!g.Response) {
const { Response } = require("undici"); const { Response } = require("undici");
global.Response = Response; g.Response = Response;
} }
}); });
@@ -164,12 +151,101 @@ afterEach(() => {
server.resetHandlers(); server.resetHandlers();
}); });
/**
* Mock R2 bucket implementation
* Simulates Cloudflare Workers R2 storage using an in-memory Map
*/
export class MockR2 {
private store: Map<
string,
{
body: ArrayBuffer;
httpMetadata?: { contentType?: string; contentDisposition?: string };
httpEtag: string;
}
> = new Map();
async put(
key: string,
value:
| ReadableStream
| ArrayBuffer
| ArrayBufferView
| string
| null
| Blob,
options?: {
httpMetadata?: { contentType?: string; contentDisposition?: string };
},
) {
let buffer: ArrayBuffer;
if (value instanceof ArrayBuffer) {
buffer = value;
} else if (typeof value === "string") {
buffer = new TextEncoder().encode(value).buffer as ArrayBuffer;
} else if (ArrayBuffer.isView(value)) {
buffer = value.buffer as ArrayBuffer;
} else {
buffer = new ArrayBuffer(0);
}
this.store.set(key, {
body: buffer,
httpMetadata: options?.httpMetadata,
httpEtag: `"mock-etag-${key}"`,
});
}
async get(key: string) {
const entry = this.store.get(key);
if (!entry) return null;
return {
key,
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(entry.body));
controller.close();
},
}),
httpEtag: entry.httpEtag,
httpMetadata: entry.httpMetadata,
size: entry.body.byteLength,
arrayBuffer: async () => entry.body,
text: async () => new TextDecoder().decode(entry.body),
writeHttpMetadata: (headers: Headers) => {
if (entry.httpMetadata?.contentType) {
headers.set("Content-Type", entry.httpMetadata.contentType);
}
if (entry.httpMetadata?.contentDisposition) {
headers.set(
"Content-Disposition",
entry.httpMetadata.contentDisposition,
);
}
},
};
}
async delete(keys: string | string[]) {
const arr = Array.isArray(keys) ? keys : [keys];
for (const k of arr) {
this.store.delete(k);
}
}
_has(key: string) {
return this.store.has(key);
}
}
/** /**
* Create a mock environment for testing * Create a mock environment for testing
* @returns Mock environment with KV storage and configuration * @returns Mock environment with KV storage and configuration
*/ */
export const createMockEnv = () => ({ export const createMockEnv = (options: { withR2?: boolean } = {}) => ({
EMAIL_STORAGE: new MockKV(), EMAIL_STORAGE: new MockKV(),
DOMAIN: "test.getmynews.app", DOMAIN: "test.getmynews.app",
ADMIN_PASSWORD: "test-password", ADMIN_PASSWORD: "test-password",
...(options.withR2
? { ATTACHMENT_BUCKET: new MockR2() as unknown as R2Bucket }
: {}),
}); });
+4
View File
@@ -0,0 +1,4 @@
declare module "*.css" {
const content: string;
export default content;
}
+3 -3
View File
@@ -1,8 +1,8 @@
import { Env } from './index'; import { Env } from "./index";
// Extend Hono's types to include our custom environment // Extend Hono's types to include our custom environment
declare module 'hono' { declare module "hono" {
interface ContextVariableMap { interface ContextVariableMap {
env: Env; env: Env;
} }
} }
+26 -3
View File
@@ -3,6 +3,19 @@ export interface Env {
EMAIL_STORAGE: KVNamespace; EMAIL_STORAGE: KVNamespace;
ADMIN_PASSWORD: string; ADMIN_PASSWORD: string;
DOMAIN: string; DOMAIN: string;
EMAIL_DOMAIN?: string;
ATTACHMENT_BUCKET?: R2Bucket;
FEED_MAX_SIZE_BYTES?: string;
PROXY_TRUSTED_IPS?: string;
PROXY_AUTH_SECRET?: string;
}
// Stored attachment metadata (bytes live in R2, keyed by id)
export interface AttachmentData {
id: string;
filename: string;
contentType: string;
size: number;
} }
// Email interface for stored emails // Email interface for stored emails
@@ -12,6 +25,7 @@ export interface EmailData {
content: string; content: string;
receivedAt: number; receivedAt: number;
headers: Record<string, string>; headers: Record<string, string>;
attachments?: AttachmentData[];
} }
// Feed configuration interface // Feed configuration interface
@@ -19,9 +33,8 @@ export interface FeedConfig {
title: string; title: string;
description?: string; description?: string;
allowed_senders?: string[]; allowed_senders?: string[];
blocked_senders?: string[];
language: string; language: string;
site_url: string;
feed_url: string;
author?: string; author?: string;
created_at: number; created_at: number;
updated_at?: number; updated_at?: number;
@@ -37,6 +50,8 @@ export interface EmailMetadata {
key: string; key: string;
subject: string; subject: string;
receivedAt: number; receivedAt: number;
size?: number;
attachmentIds?: string[];
} }
// Feed list interface // Feed list interface
@@ -51,12 +66,20 @@ export interface FeedListItem {
description?: string; description?: string;
} }
// WebSub (PubSubHubbub) subscription configuration
export interface WebSubSubscription {
callbackUrl: string;
secret?: string;
expiresAt: number; // Unix timestamp ms
format?: "rss" | "atom";
}
// Declare KVNamespace for TypeScript // Declare KVNamespace for TypeScript
declare global { declare global {
// This is not an ideal solution but works for our example // This is not an ideal solution but works for our example
interface KVNamespace { interface KVNamespace {
get(key: string, options?: { type: "text" }): Promise<string | null>; get(key: string, options?: { type: "text" }): Promise<string | null>;
get(key: string, options: { type: "json" }): Promise<any | null>; get(key: string, options: { type: "json" }): Promise<unknown | null>;
get( get(
key: string, key: string,
options: { type: "arrayBuffer" }, options: { type: "arrayBuffer" },
+7 -4
View File
@@ -1,8 +1,11 @@
// Extend mailparser types for Buffer in worker environment // Extend mailparser types for Buffer in worker environment
declare module 'buffer-polyfill' { declare module "buffer-polyfill" {
global { global {
var Buffer: { var Buffer: {
from(data: string, encoding?: string): { from(
data: string,
encoding?: string,
): {
toString(encoding?: string): string; toString(encoding?: string): string;
}; };
}; };
@@ -10,8 +13,8 @@ declare module 'buffer-polyfill' {
} }
// Add missing atob declaration // Add missing atob declaration
declare module 'atob-polyfill' { declare module "atob-polyfill" {
global { global {
function atob(data: string): string; function atob(data: string): string;
} }
} }
+4 -4
View File
@@ -1,4 +1,4 @@
declare module 'rss' { declare module "rss" {
interface RSSOptions { interface RSSOptions {
title: string; title: string;
description: string; description: string;
@@ -15,7 +15,7 @@ declare module 'rss' {
generator?: string; generator?: string;
categories?: string[]; categories?: string[];
custom_namespaces?: Record<string, string>; custom_namespaces?: Record<string, string>;
custom_elements?: any[]; custom_elements?: unknown[];
} }
interface RSSItemOptions { interface RSSItemOptions {
@@ -34,7 +34,7 @@ declare module 'rss' {
size?: number; size?: number;
type?: string; type?: string;
}; };
custom_elements?: any[]; custom_elements?: unknown[];
} }
interface RSSXMLOptions { interface RSSXMLOptions {
@@ -48,4 +48,4 @@ declare module 'rss' {
} }
export = RSS; export = RSS;
} }
+185
View File
@@ -0,0 +1,185 @@
import { describe, it, expect } from "vitest";
import { EmailParser } from "./email-parser";
describe("EmailParser.extractFeedId", () => {
it("extracts a valid feed ID from an email address", () => {
expect(EmailParser.extractFeedId("[email protected]")).toBe(
"river.castle.42",
);
});
it("is case-insensitive for the local part", () => {
expect(EmailParser.extractFeedId("[email protected]")).toBe(
"River.Castle.42",
);
});
it("returns null for an address with no feed ID format", () => {
expect(EmailParser.extractFeedId("[email protected]")).toBeNull();
});
it("returns null for a plain string without @", () => {
expect(EmailParser.extractFeedId("notanemail")).toBeNull();
});
it("returns null when the numeric suffix is only one digit", () => {
expect(EmailParser.extractFeedId("[email protected]")).toBeNull();
});
it("returns null when the numeric suffix has more than two digits", () => {
expect(
EmailParser.extractFeedId("[email protected]"),
).toBeNull();
});
});
describe("EmailParser.decodeEncodedWords", () => {
it("returns plain text unchanged", () => {
expect(EmailParser.decodeEncodedWords("Hello World")).toBe("Hello World");
});
it("returns empty string for empty input", () => {
expect(EmailParser.decodeEncodedWords("")).toBe("");
});
it("decodes a Base64-encoded word (UTF-8 subject)", () => {
// =?UTF-8?B?SGVsbG8=?= → "Hello"
expect(EmailParser.decodeEncodedWords("=?UTF-8?B?SGVsbG8=?=")).toBe(
"Hello",
);
});
it("decodes a quoted-printable encoded word", () => {
// =?UTF-8?Q?caf=C3=A9?= → "café" (but decodeQuotedPrintable works byte-by-byte)
// Use a simple ASCII QP sequence to stay charset-agnostic in tests
// =?US-ASCII?Q?Hello=20World?= → "Hello World" (=20 → space, _ → space)
expect(EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello=20World?=")).toBe(
"Hello World",
);
});
it("decodes underscores as spaces in QP encoding", () => {
expect(EmailParser.decodeEncodedWords("=?US-ASCII?Q?Hello_World?=")).toBe(
"Hello World",
);
});
it("leaves unrecognised encoded-word syntax unchanged", () => {
expect(EmailParser.decodeEncodedWords("=?UTF-8?X?something?=")).toBe(
"=?UTF-8?X?something?=",
);
});
});
describe("EmailParser.parseForwardEmailPayload", () => {
it("throws on null payload", () => {
expect(() => EmailParser.parseForwardEmailPayload(null)).toThrow(
"Missing or invalid webhook payload",
);
});
it("throws on undefined payload", () => {
expect(() => EmailParser.parseForwardEmailPayload(undefined)).toThrow();
});
it("parses subject, from, and HTML content", () => {
const payload = {
subject: "Test Subject",
from: { text: "[email protected]" },
html: "<p>Hello</p>",
date: "2024-01-15T10:00:00.000Z",
};
const result = EmailParser.parseForwardEmailPayload(payload);
expect(result.subject).toBe("Test Subject");
expect(result.from).toBe("[email protected]");
expect(result.content).toBe("<p>Hello</p>");
expect(result.receivedAt).toBe(
new Date("2024-01-15T10:00:00.000Z").getTime(),
);
});
it("prefers HTML content over plain text", () => {
const payload = {
from: { text: "[email protected]" },
html: "<b>HTML</b>",
text: "Plain",
};
expect(EmailParser.parseForwardEmailPayload(payload).content).toBe(
"<b>HTML</b>",
);
});
it("falls back to plain text when HTML is absent", () => {
const payload = {
from: { text: "[email protected]" },
text: "Plain text",
};
expect(EmailParser.parseForwardEmailPayload(payload).content).toBe(
"Plain text",
);
});
it("uses structured from.value when from.text is absent", () => {
const payload = {
from: {
value: [{ name: "Alice", address: "[email protected]" }],
},
html: "",
};
const result = EmailParser.parseForwardEmailPayload(payload);
expect(result.from).toBe("Alice <[email protected]>");
});
it("falls back to Unknown Sender when from is absent", () => {
const result = EmailParser.parseForwardEmailPayload({ html: "" });
expect(result.from).toBe("Unknown Sender");
});
it("uses Date.now() when date field is absent", () => {
const before = Date.now();
const result = EmailParser.parseForwardEmailPayload({
from: { text: "[email protected]" },
});
const after = Date.now();
expect(result.receivedAt).toBeGreaterThanOrEqual(before);
expect(result.receivedAt).toBeLessThanOrEqual(after);
});
it("defaults subject to 'No Subject' when absent", () => {
const result = EmailParser.parseForwardEmailPayload({
from: { text: "[email protected]" },
});
expect(result.subject).toBe("No Subject");
});
it("extracts headers from headerLines array", () => {
const payload = {
from: { text: "[email protected]" },
headerLines: [
{ key: "X-Custom", line: "X-Custom: my-value" },
{ key: "List-ID", line: "List-ID: <list.example.com>" },
],
};
const result = EmailParser.parseForwardEmailPayload(payload);
expect(result.headers["x-custom"]).toBe("my-value");
expect(result.headers["list-id"]).toBe("<list.example.com>");
});
it("extracts headers from raw headers string", () => {
const payload = {
from: { text: "[email protected]" },
headers: "X-Foo: bar\r\nX-Baz: qux",
};
const result = EmailParser.parseForwardEmailPayload(payload);
expect(result.headers["x-foo"]).toBe("bar");
expect(result.headers["x-baz"]).toBe("qux");
});
it("decodes RFC 2047 encoded-word subjects", () => {
const payload = {
from: { text: "[email protected]" },
subject: "=?UTF-8?B?SGVsbG8=?=",
};
expect(EmailParser.parseForwardEmailPayload(payload).subject).toBe("Hello");
});
});
+44 -76
View File
@@ -1,72 +1,49 @@
import { EmailData } from '../types'; import { EmailData } from "../types";
/**
* Simple email parser specialized for ForwardEmail.net's webhook format
*/
export class EmailParser { export class EmailParser {
/** // Matches noun1.noun2.XY (the feed ID format) before the @ symbol
* Extract the feed ID from an email address
* @param emailAddress The email address (e.g., apple.mountain.42@domain.com)
* @returns The feed ID or null if not found
*/
static extractFeedId(emailAddress: string): string | null { static extractFeedId(emailAddress: string): string | null {
// Match pattern for noun1.noun2.XY before the @ symbol
const match = emailAddress.match(/^([a-z]+\.[a-z]+\.\d{2})@/i); const match = emailAddress.match(/^([a-z]+\.[a-z]+\.\d{2})@/i);
return match ? match[1] : null; return match ? match[1] : null;
} }
/** // eslint-disable-next-line @typescript-eslint/no-explicit-any
* Parse email data from ForwardEmail.net's webhook payload
* @param payload ForwardEmail.net webhook payload
*/
static parseForwardEmailPayload(payload: any): EmailData { static parseForwardEmailPayload(payload: any): EmailData {
if (!payload) { if (!payload) {
throw new Error('Missing or invalid webhook payload'); throw new Error("Missing or invalid webhook payload");
} }
// Extract the "to" address const fromAddress =
const toAddress = payload.recipients?.[0] || ''; payload.from?.text ||
(payload.from?.value?.[0]?.address
// Extract the sender information using ForwardEmail's structure ? `${payload.from.value[0].name || ""} <${payload.from.value[0].address}>`
const fromAddress = payload.from?.text || : "Unknown Sender");
(payload.from?.value?.[0]?.address ?
`${payload.from.value[0].name || ''} <${payload.from.value[0].address}>` : const subject = this.decodeEncodedWords(payload.subject || "No Subject");
'Unknown Sender'); const content = payload.html || payload.text || "";
// Extract subject
let subject = payload.subject || 'No Subject';
// Decode any encoded words in the subject
subject = this.decodeEncodedWords(subject);
// Get content, preferring HTML over plain text
const content = payload.html || payload.text || '';
// Create simple email data object
return { return {
subject, subject,
from: fromAddress, from: fromAddress,
content, content,
receivedAt: payload.date ? new Date(payload.date).getTime() : Date.now(), receivedAt: payload.date ? new Date(payload.date).getTime() : Date.now(),
headers: this.extractHeaders(payload) headers: this.extractHeaders(payload),
}; };
} }
/** // eslint-disable-next-line @typescript-eslint/no-explicit-any
* Extract headers from ForwardEmail payload
*/
private static extractHeaders(payload: any): Record<string, string> { private static extractHeaders(payload: any): Record<string, string> {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
// Extract headers from headerLines if available
if (payload.headerLines && Array.isArray(payload.headerLines)) { if (payload.headerLines && Array.isArray(payload.headerLines)) {
payload.headerLines.forEach((h: {key: string; line: string}) => { payload.headerLines.forEach((h: { key: string; line: string }) => {
const key = h.key.toLowerCase(); const key = h.key.toLowerCase();
const value = h.line.replace(new RegExp(`^${h.key}:\\s*`, 'i'), '').trim(); const value = h.line
.replace(new RegExp(`^${h.key}:\\s*`, "i"), "")
.trim();
headers[key] = value; headers[key] = value;
}); });
} } else if (typeof payload.headers === "string") {
// Or from headers string if provided
else if (typeof payload.headers === 'string') {
payload.headers.split(/\r?\n/).forEach((line: string) => { payload.headers.split(/\r?\n/).forEach((line: string) => {
const match = line.match(/^([^:]+):\s*(.*)$/); const match = line.match(/^([^:]+):\s*(.*)$/);
if (match) { if (match) {
@@ -74,42 +51,33 @@ export class EmailParser {
} }
}); });
} }
return headers; return headers;
} }
/**
* Decode RFC 2047 encoded words in headers
* @param text Text that may contain encoded words like =?UTF-8?Q?Hello_World?=
*/
static decodeEncodedWords(text: string): string { static decodeEncodedWords(text: string): string {
if (!text) return ''; if (!text) return "";
// Simple RFC 2047 encoded-word decoder return text.replace(
return text.replace(/=\?([^?]+)\?([BQ])\?([^?]+)\?=/gi, (_, charset, encoding, text) => { /=\?([^?]+)\?([BQ])\?([^?]+)\?=/gi,
if (encoding.toUpperCase() === 'B') { (_, charset, encoding, text) => {
// Base64 encoding if (encoding.toUpperCase() === "B") {
try { try {
const decoded = atob(text); return atob(text);
return decoded; } catch {
} catch (e) { return text;
return text; }
} else if (encoding.toUpperCase() === "Q") {
return this.decodeQuotedPrintable(text.replace(/_/g, " "));
} }
} else if (encoding.toUpperCase() === 'Q') { return text;
// Quoted-printable encoding },
return this.decodeQuotedPrintable(text.replace(/_/g, ' ')); );
}
return text;
});
} }
/**
* Decode quoted-printable encoded text
* @param text Quoted-printable encoded text
*/
private static decodeQuotedPrintable(text: string): string { private static decodeQuotedPrintable(text: string): string {
return text.replace(/=([0-9A-F]{2})/gi, (_, hex) => { return text.replace(/=([0-9A-F]{2})/gi, (_, hex) => {
return String.fromCharCode(parseInt(hex, 16)); return String.fromCharCode(parseInt(hex, 16));
}); });
} }
} }
+40
View File
@@ -0,0 +1,40 @@
import { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
import { MAX_FEED_ITEMS } from "../config/constants";
export interface FeedData {
feedConfig: FeedConfig;
emails: EmailData[];
}
export async function fetchFeedData(
feedId: string,
env: Env,
): Promise<FeedData | null> {
const storage = env.EMAIL_STORAGE;
const feedMetadata = (await storage.get(
`feed:${feedId}:metadata`,
"json",
)) as FeedMetadata | null;
if (!feedMetadata) return null;
const feedConfig = ((await storage.get(
`feed:${feedId}:config`,
"json",
)) as FeedConfig | null) ?? {
title: `Newsletter Feed ${feedId}`,
description: "Converted email newsletter",
language: "en",
created_at: Date.now(),
};
const emailRefs = feedMetadata.emails.slice(0, MAX_FEED_ITEMS);
const emails: EmailData[] = [];
for (const ref of emailRefs) {
const data = (await storage.get(ref.key, "json")) as EmailData | null;
if (data) emails.push(data);
}
return { feedConfig, emails };
}
+314
View File
@@ -0,0 +1,314 @@
import { describe, it, expect } from "vitest";
import {
generateRssFeed,
generateAtomFeed,
extractBodyContent,
} from "./feed-generator";
import { FeedConfig, EmailData } from "../types";
const mockFeedConfig: FeedConfig = {
title: "Test Newsletter",
description: "A test feed",
language: "en",
created_at: 1700000000000,
};
const mockEmails: EmailData[] = [
{
subject: "Hello World",
from: "Alice <[email protected]>",
content: "<p>Hello from Alice</p>",
receivedAt: 1700000001000,
headers: {},
},
];
const mockEmailWithAttachment: EmailData = {
...mockEmails[0],
attachments: [
{
id: "550e8400-e29b-41d4-a716-446655440000",
filename: "report.pdf",
contentType: "application/pdf",
size: 12345,
},
],
};
const BASE_URL = "https://test.getmynews.app";
const FEED_ID = "abc123";
describe("extractBodyContent", () => {
it("extracts content inside <body> tags", () => {
const html = "<html><head></head><body><p>Hello</p></body></html>";
expect(extractBodyContent(html)).toBe("<p>Hello</p>");
});
it("handles body tag with attributes", () => {
const html = '<html><body style="margin:0"><p>Hi</p></body></html>';
expect(extractBodyContent(html)).toBe("<p>Hi</p>");
});
it("returns html unchanged when no body tags present", () => {
const fragment = "<p>Already a fragment</p>";
expect(extractBodyContent(fragment)).toBe(fragment);
});
it("is case-insensitive for body tag matching", () => {
const html = "<HTML><BODY><p>content</p></BODY></HTML>";
expect(extractBodyContent(html)).toBe("<p>content</p>");
});
});
describe("generateRssFeed", () => {
it("returns RSS 2.0 with channel element", () => {
const result = generateRssFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("<channel>");
expect(result).toContain("<title>Test Newsletter</title>");
});
it("includes <enclosure> element for email with attachment", () => {
const result = generateRssFeed(
mockFeedConfig,
[mockEmailWithAttachment],
BASE_URL,
FEED_ID,
);
expect(result).toContain("<enclosure");
expect(result).toContain("550e8400-e29b-41d4-a716-446655440000");
expect(result).toContain("report.pdf");
expect(result).toContain("application/pdf");
expect(result).toContain("12345");
});
it("does not include <enclosure> for email without attachments", () => {
const result = generateRssFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).not.toContain("<enclosure");
});
it("enclosure URL uses /files/{id}/{filename} scheme", () => {
const result = generateRssFeed(
mockFeedConfig,
[mockEmailWithAttachment],
BASE_URL,
FEED_ID,
);
expect(result).toContain(
`${BASE_URL}/files/550e8400-e29b-41d4-a716-446655440000/report.pdf`,
);
});
it("includes rss self-link in RSS output", () => {
const result = generateRssFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain(`${BASE_URL}/rss/${FEED_ID}`);
});
it("includes email entries as <item> elements", () => {
const result = generateRssFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("<item>");
expect(result).toContain("Hello World");
});
it("works with empty emails array", () => {
const result = generateRssFeed(mockFeedConfig, [], BASE_URL, FEED_ID);
expect(result).toContain("<channel>");
expect(result).not.toContain("<item>");
});
it("feed link points to admin emails page", () => {
const result = generateRssFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain(`${BASE_URL}/admin/feeds/${FEED_ID}/emails`);
});
it("strips html/head/body wrapper from item description", () => {
const emailWithFullHtml: EmailData = {
...mockEmails[0],
content: "<html><head></head><body><p>Body only</p></body></html>",
};
const result = generateRssFeed(
mockFeedConfig,
[emailWithFullHtml],
BASE_URL,
FEED_ID,
);
expect(result).toContain("<p>Body only</p>");
expect(result).not.toContain("<html>");
});
});
describe("generateAtomFeed", () => {
it("returns Atom 1.0 namespace", () => {
const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain('xmlns="http://www.w3.org/2005/Atom"');
});
it("contains <feed> root element", () => {
const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("<feed");
expect(result).toContain("</feed>");
});
it("includes feed title", () => {
const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("Test Newsletter");
});
it("includes <entry> elements for each email", () => {
const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("<entry>");
expect(result).toContain("Hello World");
});
it("includes author information", () => {
const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("Alice");
});
it("self-link points to atom URL", () => {
const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain(`${BASE_URL}/atom/${FEED_ID}`);
});
it("includes rss alternate link", () => {
const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain(`${BASE_URL}/rss/${FEED_ID}`);
});
it("works with empty emails array", () => {
const result = generateAtomFeed(mockFeedConfig, [], BASE_URL, FEED_ID);
expect(result).toContain("<feed");
expect(result).not.toContain("<entry>");
});
it("feed link points to admin emails page", () => {
const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain(`${BASE_URL}/admin/feeds/${FEED_ID}/emails`);
});
it("strips html/head/body wrapper from entry content", () => {
const emailWithFullHtml: EmailData = {
...mockEmails[0],
content: "<html><head></head><body><p>Body only</p></body></html>",
};
const result = generateAtomFeed(
mockFeedConfig,
[emailWithFullHtml],
BASE_URL,
FEED_ID,
);
expect(result).toContain("<p>Body only</p>");
expect(result).not.toContain("<html>");
});
it("handles config without description", () => {
const configNoDesc: FeedConfig = {
...mockFeedConfig,
description: undefined,
};
const result = generateAtomFeed(
configNoDesc,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain('xmlns="http://www.w3.org/2005/Atom"');
});
it("handles config with author field", () => {
const configWithAuthor: FeedConfig = { ...mockFeedConfig, author: "Bob" };
const result = generateAtomFeed(
configWithAuthor,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).toContain("Bob");
});
it("includes enclosure link for email with attachment in Atom feed", () => {
const result = generateAtomFeed(
mockFeedConfig,
[mockEmailWithAttachment],
BASE_URL,
FEED_ID,
);
expect(result).toContain('rel="enclosure"');
expect(result).toContain("550e8400-e29b-41d4-a716-446655440000");
expect(result).toContain("report.pdf");
});
it("does not include enclosure link for email without attachments in Atom feed", () => {
const result = generateAtomFeed(
mockFeedConfig,
mockEmails,
BASE_URL,
FEED_ID,
);
expect(result).not.toContain('rel="enclosure"');
});
});
+88 -36
View File
@@ -1,53 +1,105 @@
import { Feed } from 'feed'; import { Feed } from "feed";
import { FeedConfig, EmailData } from '../types'; import { FeedConfig, EmailData } from "../types";
import { processEmailContent } from "./html-processor";
/** export { processEmailContent as extractBodyContent };
* Generate an RSS feed from a list of emails
*/ function parseFromAddress(from: string): { name: string; email?: string } {
export function generateRssFeed( const match = from.match(/^(.*?)\s*<([^>]+)>\s*$/);
feedConfig: FeedConfig, if (match) {
return { name: match[1].trim() || match[2], email: match[2].trim() };
}
const emailOnly = from.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
if (emailOnly) {
return { email: from.trim(), name: from.trim() };
}
return { name: from.trim() };
}
function buildFeed(
feedConfig: FeedConfig,
emails: EmailData[], emails: EmailData[],
baseUrl: string baseUrl: string,
): string { feedId: string,
// Create a new feed selfUrl?: { rss?: string; atom?: string },
): Feed {
const feed = new Feed({ const feed = new Feed({
title: feedConfig.title, title: feedConfig.title,
description: feedConfig.description || '', description: feedConfig.description || "",
id: feedConfig.feed_url, // Computed dynamically so the id is always canonical regardless of what
link: feedConfig.site_url, // was stored in KV at feed-creation time (which may have used a stale domain).
id: `${baseUrl}/rss/${feedId}`,
// Link points to the admin emails page — the "website" this feed represents.
link: `${baseUrl}/admin/feeds/${feedId}/emails`,
language: feedConfig.language, language: feedConfig.language,
updated: new Date(), updated: new Date(),
generator: 'Email-to-RSS', generator: "kill-the-news",
copyright: `Copyright © ${new Date().getFullYear()} ${feedConfig.title}`, copyright: `Copyright © ${new Date().getFullYear()} ${feedConfig.title}`,
feedLinks: { feedLinks: {
rss: feedConfig.feed_url rss: selfUrl?.rss ?? `${baseUrl}/rss/${feedId}`,
atom: selfUrl?.atom ?? `${baseUrl}/atom/${feedId}`,
}, },
author: feedConfig.author ? { author: feedConfig.author
name: feedConfig.author, ? {
email: `noreply@${new URL(feedConfig.site_url).hostname}` name: feedConfig.author,
} : undefined email: `noreply@${new URL(baseUrl).hostname}`,
}
: undefined,
}); });
// Add each email as a feed item
for (const email of emails) { for (const email of emails) {
const date = new Date(email.receivedAt); const entryUrl = `${baseUrl}/entries/${feedId}/${email.receivedAt}`;
const uniqueId = `${email.receivedAt}-${Buffer.from(email.subject).toString('base64').substring(0, 10)}`; const firstAttachment = email.attachments?.[0];
const bodyContent = processEmailContent(email.content);
feed.addItem({ feed.addItem({
title: email.subject, title: email.subject,
id: uniqueId, id: entryUrl,
link: `${baseUrl}/emails/${uniqueId}`, link: entryUrl,
description: email.content, description: bodyContent,
content: email.content, content: bodyContent,
author: [ author: [parseFromAddress(email.from)],
{ date: new Date(email.receivedAt),
name: email.from, enclosure: firstAttachment
}, ? {
], url: `${baseUrl}/files/${firstAttachment.id}/${encodeURIComponent(firstAttachment.filename)}`,
date: date, type: firstAttachment.contentType,
length: firstAttachment.size,
}
: undefined,
}); });
} }
// Return the RSS feed as XML return feed;
return feed.rss2(); }
}
export function generateRssFeed(
feedConfig: FeedConfig,
emails: EmailData[],
baseUrl: string,
feedId: string,
selfUrl?: string,
): string {
return buildFeed(
feedConfig,
emails,
baseUrl,
feedId,
selfUrl ? { rss: selfUrl } : undefined,
).rss2();
}
export function generateAtomFeed(
feedConfig: FeedConfig,
emails: EmailData[],
baseUrl: string,
feedId: string,
selfUrl?: string,
): string {
return buildFeed(
feedConfig,
emails,
baseUrl,
feedId,
selfUrl ? { atom: selfUrl } : undefined,
).atom1();
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect } from "vitest";
import { processEmailContent } from "./html-processor";
describe("processEmailContent — body extraction", () => {
it("extracts content inside <body> tags", () => {
const html = "<html><head></head><body><p>Hello</p></body></html>";
expect(processEmailContent(html)).toBe("<p>Hello</p>");
});
it("handles body tag with attributes", () => {
const html = '<html><body style="margin:0"><p>Hi</p></body></html>';
expect(processEmailContent(html)).toBe("<p>Hi</p>");
});
it("returns fragment unchanged when no body tags present", () => {
const fragment = "<p>Already a fragment</p>";
expect(processEmailContent(fragment)).toBe("<p>Already a fragment</p>");
});
it("is case-insensitive for body tag matching", () => {
const html = "<HTML><BODY><p>content</p></BODY></HTML>";
expect(processEmailContent(html)).toBe("<p>content</p>");
});
});
describe("processEmailContent — plain text", () => {
it("wraps plain text in <pre>", () => {
const text = "Hello world\nSecond line";
const result = processEmailContent(text);
expect(result).toMatch(/^<pre /);
expect(result).toContain("Hello world\nSecond line");
});
it("escapes < and > in plain text", () => {
const text = "Price < 10 & size > 5";
const result = processEmailContent(text);
expect(result).toContain("&lt;");
expect(result).toContain("&gt;");
expect(result).toContain("&amp;");
expect(result).not.toContain("<10");
});
it("returns empty string for empty input", () => {
expect(processEmailContent("")).toBe("");
});
});
describe("processEmailContent — dangerous element removal", () => {
it("removes <script> tags", () => {
const html = "<body><p>Hello</p><script>alert('xss')</script></body>";
const result = processEmailContent(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("alert");
expect(result).toContain("<p>Hello</p>");
});
it("removes <iframe> tags", () => {
const html =
"<body><iframe src='https://evil.com'></iframe><p>ok</p></body>";
const result = processEmailContent(html);
expect(result).not.toContain("<iframe");
expect(result).toContain("<p>ok</p>");
});
it("removes <object> and <embed> tags", () => {
const html = "<body><object></object><embed src='x'/><p>ok</p></body>";
const result = processEmailContent(html);
expect(result).not.toContain("<object");
expect(result).not.toContain("<embed");
});
});
describe("processEmailContent — attribute sanitization", () => {
it("removes event handler attributes", () => {
const html =
"<body><a href='https://x.com' onclick='evil()'>link</a></body>";
const result = processEmailContent(html);
expect(result).not.toContain("onclick");
expect(result).toContain('href="https://x.com"');
});
it("removes onerror on images", () => {
const html = "<body><img src='x' onerror='evil()' /></body>";
const result = processEmailContent(html);
expect(result).not.toContain("onerror");
});
it("removes javascript: hrefs", () => {
const html = "<body><a href='javascript:evil()'>click</a></body>";
const result = processEmailContent(html);
expect(result).not.toContain("javascript:");
});
it("preserves legitimate href and src attributes", () => {
const html =
"<body><a href='https://example.com'>link</a><img src='https://example.com/img.png'/></body>";
const result = processEmailContent(html);
expect(result).toContain("https://example.com");
});
});
describe("processEmailContent — mso style cleanup", () => {
it("strips mso-* properties from inline styles", () => {
const html =
'<body><p style="mso-margin-top: 0; color: red;">text</p></body>';
const result = processEmailContent(html);
expect(result).not.toContain("mso-margin-top");
expect(result).toContain("color: red");
});
it("removes style attribute entirely when only mso properties remain", () => {
const html =
'<body><p style="mso-line-height-rule: exactly;">text</p></body>';
const result = processEmailContent(html);
expect(result).not.toContain("style=");
});
it("preserves style attribute when non-mso properties remain", () => {
const html =
'<body><p style="mso-font-size: 12pt; font-weight: bold;">text</p></body>';
const result = processEmailContent(html);
expect(result).toContain("font-weight");
expect(result).not.toContain("mso-font-size");
});
});
+73
View File
@@ -0,0 +1,73 @@
import { parseHTML } from "linkedom";
import escapeHtml from "escape-html";
function cleanMsoStyles(style: string): string {
return style
.split(";")
.map((p) => p.trim())
.filter((p) => p && !/^mso-/i.test(p))
.join("; ");
}
function isPlainText(content: string): boolean {
return !/<[a-z][\s\S]*>/i.test(content);
}
function sanitizeElement(el: Element): void {
// Snapshot attribute names before mutating (linkedom attributes is array-like)
const attrs = Array.from(
el.attributes as unknown as ArrayLike<{ name: string }>,
).map((a) => a.name);
for (const attr of attrs) {
// Remove event handlers (onclick, onerror, onload, …)
if (/^on/i.test(attr)) {
el.removeAttribute(attr);
continue;
}
// Remove javascript: URLs
if (["href", "src", "action"].includes(attr.toLowerCase())) {
const val = el.getAttribute(attr) ?? "";
if (/^\s*javascript:/i.test(val)) {
el.removeAttribute(attr);
continue;
}
}
}
// Strip mso-* inline style properties (Office HTML noise)
const style = el.getAttribute("style");
if (style !== null) {
const cleaned = cleanMsoStyles(style);
if (cleaned) {
el.setAttribute("style", cleaned);
} else {
el.removeAttribute("style");
}
}
}
/**
* Processes email content for safe display in feeds and entry pages:
* - Detects plain text and wraps it in a <pre> block
* - Extracts the <body> fragment from full HTML documents
* - Removes dangerous elements: <script>, <iframe>, <object>, <embed>
* - Removes event handler attributes and javascript: URLs
* - Strips mso-* inline style properties (Office HTML)
*/
export function processEmailContent(content: string): string {
if (!content) return "";
if (isPlainText(content)) {
return `<pre style="white-space: pre-wrap; word-break: break-word;">${escapeHtml(content)}</pre>`;
}
const { document } = parseHTML(content);
document
.querySelectorAll("script, object, embed, iframe, frame, frameset")
.forEach((el: Element) => el.remove());
document.querySelectorAll("*").forEach((el: Element) => sanitizeElement(el));
const body = document.querySelector("body");
return body ? body.innerHTML : content;
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { nouns } from '../data/nouns'; import { nouns } from "../data/nouns";
/** /**
* Generates a random feed ID in the format noun1.noun2.XY * Generates a random feed ID in the format noun1.noun2.XY
@@ -8,10 +8,10 @@ export function generateFeedId(): string {
// Select two random nouns // Select two random nouns
const noun1 = nouns[Math.floor(Math.random() * nouns.length)]; const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
const noun2 = nouns[Math.floor(Math.random() * nouns.length)]; const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
// Generate a random 2-digit number between 10 and 99 // Generate a random 2-digit number between 10 and 99
const number = Math.floor(Math.random() * 90) + 10; const number = Math.floor(Math.random() * 90) + 10;
// Combine to create the ID with dots as separators // Combine to create the ID with dots as separators
return `${noun1}.${noun2}.${number}`; return `${noun1}.${noun2}.${number}`;
} }
+64 -40
View File
@@ -1,4 +1,11 @@
import { EmailData, FeedConfig, FeedMetadata, FeedList, EmailMetadata } from '../types'; import {
EmailData,
FeedConfig,
FeedMetadata,
FeedList,
EmailMetadata,
} from "../types";
import { MAX_METADATA_EMAILS } from "../config/constants";
/** /**
* Store email data in KV * Store email data in KV
@@ -6,22 +13,22 @@ import { EmailData, FeedConfig, FeedMetadata, FeedList, EmailMetadata } from '..
export async function storeEmail( export async function storeEmail(
kv: KVNamespace, kv: KVNamespace,
feedId: string, feedId: string,
emailData: EmailData emailData: EmailData,
): Promise<string> { ): Promise<string> {
// Generate a unique key for this email // Generate a unique key for this email
const timestamp = Date.now(); const timestamp = Date.now();
const key = `feed:${feedId}:email:${timestamp}`; const key = `feed:${feedId}:email:${timestamp}`;
// Store the email content // Store the email content
await kv.put(key, JSON.stringify(emailData)); await kv.put(key, JSON.stringify(emailData));
// Update the feed's metadata (list of emails) // Update the feed's metadata (list of emails)
await updateFeedMetadata(kv, feedId, { await updateFeedMetadata(kv, feedId, {
key, key,
subject: emailData.subject, subject: emailData.subject,
receivedAt: timestamp receivedAt: timestamp,
}); });
return key; return key;
} }
@@ -31,23 +38,31 @@ export async function storeEmail(
async function updateFeedMetadata( async function updateFeedMetadata(
kv: KVNamespace, kv: KVNamespace,
feedId: string, feedId: string,
emailMetadata: EmailMetadata emailMetadata: EmailMetadata,
): Promise<void> { ): Promise<void> {
const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadataKey = `feed:${feedId}:metadata`;
const existingMetadata = await kv.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null; const existingMetadata = (await kv.get(feedMetadataKey, {
type: "json",
})) as FeedMetadata | null;
const metadata: FeedMetadata = existingMetadata || { emails: [] }; const metadata: FeedMetadata = existingMetadata || { emails: [] };
// Add new email to the beginning of the list // Add new email to the beginning of the list
metadata.emails.unshift(emailMetadata); metadata.emails.unshift(emailMetadata);
// Keep only the last 50 emails in the metadata // Keep only the last MAX_METADATA_EMAILS in the metadata; delete orphaned KV entries
if (metadata.emails.length > 50) { const toDelete =
metadata.emails = metadata.emails.slice(0, 50); metadata.emails.length > MAX_METADATA_EMAILS
? metadata.emails.slice(MAX_METADATA_EMAILS)
: [];
if (toDelete.length > 0) {
metadata.emails = metadata.emails.slice(0, MAX_METADATA_EMAILS);
} }
// Store updated metadata await Promise.all([
await kv.put(feedMetadataKey, JSON.stringify(metadata)); kv.put(feedMetadataKey, JSON.stringify(metadata)),
...toDelete.map((e) => kv.delete(e.key)),
]);
} }
/** /**
@@ -55,10 +70,12 @@ async function updateFeedMetadata(
*/ */
export async function getFeedMetadata( export async function getFeedMetadata(
kv: KVNamespace, kv: KVNamespace,
feedId: string feedId: string,
): Promise<FeedMetadata | null> { ): Promise<FeedMetadata | null> {
const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadataKey = `feed:${feedId}:metadata`;
return await kv.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null; return (await kv.get(feedMetadataKey, {
type: "json",
})) as FeedMetadata | null;
} }
/** /**
@@ -66,10 +83,10 @@ export async function getFeedMetadata(
*/ */
export async function getFeedConfig( export async function getFeedConfig(
kv: KVNamespace, kv: KVNamespace,
feedId: string feedId: string,
): Promise<FeedConfig | null> { ): Promise<FeedConfig | null> {
const feedConfigKey = `feed:${feedId}:config`; const feedConfigKey = `feed:${feedId}:config`;
return await kv.get(feedConfigKey, { type: 'json' }) as FeedConfig | null; return (await kv.get(feedConfigKey, { type: "json" })) as FeedConfig | null;
} }
/** /**
@@ -77,9 +94,9 @@ export async function getFeedConfig(
*/ */
export async function getEmailData( export async function getEmailData(
kv: KVNamespace, kv: KVNamespace,
key: string key: string,
): Promise<EmailData | null> { ): Promise<EmailData | null> {
return await kv.get(key, { type: 'json' }) as EmailData | null; return (await kv.get(key, { type: "json" })) as EmailData | null;
} }
/** /**
@@ -88,18 +105,21 @@ export async function getEmailData(
export async function createFeed( export async function createFeed(
kv: KVNamespace, kv: KVNamespace,
feedId: string, feedId: string,
feedConfig: FeedConfig feedConfig: FeedConfig,
): Promise<void> { ): Promise<void> {
// Store feed configuration // Store feed configuration
const feedConfigKey = `feed:${feedId}:config`; const feedConfigKey = `feed:${feedId}:config`;
await kv.put(feedConfigKey, JSON.stringify(feedConfig)); await kv.put(feedConfigKey, JSON.stringify(feedConfig));
// Create empty metadata for the feed // Create empty metadata for the feed
const feedMetadataKey = `feed:${feedId}:metadata`; const feedMetadataKey = `feed:${feedId}:metadata`;
await kv.put(feedMetadataKey, JSON.stringify({ await kv.put(
emails: [] feedMetadataKey,
})); JSON.stringify({
emails: [],
}),
);
// Add feed to the list of all feeds // Add feed to the list of all feeds
await addFeedToList(kv, feedId, feedConfig.title, feedConfig.description); await addFeedToList(kv, feedId, feedConfig.title, feedConfig.description);
} }
@@ -111,19 +131,21 @@ export async function addFeedToList(
kv: KVNamespace, kv: KVNamespace,
feedId: string, feedId: string,
title: string, title: string,
description?: string description?: string,
): Promise<void> { ): Promise<void> {
const feedListKey = 'feeds:list'; const feedListKey = "feeds:list";
const existingList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null; const existingList = (await kv.get(feedListKey, {
type: "json",
})) as FeedList | null;
const feedList: FeedList = existingList || { feeds: [] }; const feedList: FeedList = existingList || { feeds: [] };
feedList.feeds.push({ feedList.feeds.push({
id: feedId, id: feedId,
title, title,
description description,
}); });
await kv.put(feedListKey, JSON.stringify(feedList)); await kv.put(feedListKey, JSON.stringify(feedList));
} }
@@ -131,8 +153,10 @@ export async function addFeedToList(
* Get all feeds * Get all feeds
*/ */
export async function getAllFeeds(kv: KVNamespace): Promise<FeedList> { export async function getAllFeeds(kv: KVNamespace): Promise<FeedList> {
const feedListKey = 'feeds:list'; const feedListKey = "feeds:list";
const feedList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null; const feedList = (await kv.get(feedListKey, {
type: "json",
})) as FeedList | null;
return feedList || { feeds: [] }; return feedList || { feeds: [] };
} }
+30
View File
@@ -0,0 +1,30 @@
import { Env } from "../types";
export function baseUrl(env: Env): string {
return `https://${env.DOMAIN}`;
}
export function feedRssUrl(feedId: string, env: Env): string {
return `${baseUrl(env)}/rss/${feedId}`;
}
export function feedAtomUrl(feedId: string, env: Env): string {
return `${baseUrl(env)}/atom/${feedId}`;
}
export function feedUrl(
format: "rss" | "atom",
feedId: string,
env: Env,
): string {
return format === "rss" ? feedRssUrl(feedId, env) : feedAtomUrl(feedId, env);
}
export function feedEmailAddress(feedId: string, env: Env): string {
return `${feedId}@${env.EMAIL_DOMAIN ?? env.DOMAIN}`;
}
export function feedTopicPattern(env: Env): RegExp {
const escaped = env.DOMAIN.replaceAll(".", "\\.");
return new RegExp(`^https://${escaped}/(rss|atom)/([^/]+)$`);
}
+538
View File
@@ -0,0 +1,538 @@
import { describe, it, expect } from "vitest";
import { http, HttpResponse } from "msw";
import { server, createMockEnv } from "../test/setup";
import {
buildHmacSignature,
getSubscriptions,
saveSubscriptions,
notifySubscribers,
verifyAndStoreSubscription,
verifyAndDeleteSubscription,
subscriptionKey,
} from "./websub";
import type { Env, WebSubSubscription } from "../types";
const mockEnv = () => createMockEnv() as unknown as Env;
describe("buildHmacSignature", () => {
it("returns sha256= prefixed hex", async () => {
const sig = await buildHmacSignature("hello", "secret");
expect(sig).toMatch(/^sha256=[0-9a-f]{64}$/);
});
it("produces different sigs for different secrets", async () => {
const a = await buildHmacSignature("body", "secret1");
const b = await buildHmacSignature("body", "secret2");
expect(a).not.toBe(b);
});
it("produces the same sig for same inputs", async () => {
const a = await buildHmacSignature("body", "secret");
const b = await buildHmacSignature("body", "secret");
expect(a).toBe(b);
});
});
describe("getSubscriptions / saveSubscriptions", () => {
it("returns empty array when no subs exist", async () => {
const env = mockEnv();
expect(await getSubscriptions("feed1", env)).toEqual([]);
});
it("round-trips stored subscriptions", async () => {
const env = mockEnv();
const subs: WebSubSubscription[] = [
{
callbackUrl: "https://reader.example/sub",
expiresAt: Date.now() + 60000,
},
];
await saveSubscriptions("feed1", subs, env);
expect(await getSubscriptions("feed1", env)).toEqual(subs);
});
it("uses the correct KV key", () => {
expect(subscriptionKey("abc")).toBe("websub:subs:abc");
});
});
describe("notifySubscribers", () => {
it("does nothing when no subscriptions exist", async () => {
const env = mockEnv();
let called = false;
server.use(
http.post("https://reader.example/callback", () => {
called = true;
return HttpResponse.text("ok");
}),
);
await notifySubscribers("feed1", env);
expect(called).toBe(false);
});
it("does nothing when feed metadata missing", async () => {
const env = mockEnv();
const subs: WebSubSubscription[] = [
{
callbackUrl: "https://reader.example/callback",
expiresAt: Date.now() + 60000,
},
];
await saveSubscriptions("feed1", subs, env);
let called = false;
server.use(
http.post("https://reader.example/callback", () => {
called = true;
return HttpResponse.text("ok");
}),
);
await notifySubscribers("feed1", env);
expect(called).toBe(false);
});
it("POSTs feed XML to subscriber callback", async () => {
const env = mockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:metadata",
JSON.stringify({ emails: [] }),
);
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({
title: "Test Feed",
language: "en",
created_at: Date.now(),
}),
);
const subs: WebSubSubscription[] = [
{
callbackUrl: "https://reader.example/callback",
expiresAt: Date.now() + 60000,
},
];
await saveSubscriptions("feed1", subs, env);
let receivedBody = "";
let receivedContentType = "";
server.use(
http.post("https://reader.example/callback", async ({ request }) => {
receivedBody = await request.text();
receivedContentType = request.headers.get("Content-Type") ?? "";
return HttpResponse.text("ok");
}),
);
await notifySubscribers("feed1", env);
expect(receivedBody).toContain("<?xml");
expect(receivedContentType).toContain("application/rss+xml");
});
it("includes X-Hub-Signature-256 header when secret set (no X-Hub-Signature)", async () => {
const env = mockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:metadata",
JSON.stringify({ emails: [] }),
);
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({
title: "Test Feed",
language: "en",
created_at: Date.now(),
}),
);
const subs: WebSubSubscription[] = [
{
callbackUrl: "https://reader.example/callback",
expiresAt: Date.now() + 60000,
secret: "mysecret",
},
];
await saveSubscriptions("feed1", subs, env);
let receivedSig256 = "";
let receivedSig = "";
server.use(
http.post("https://reader.example/callback", async ({ request }) => {
receivedSig256 = request.headers.get("X-Hub-Signature-256") ?? "";
receivedSig = request.headers.get("X-Hub-Signature") ?? "";
return HttpResponse.text("ok");
}),
);
await notifySubscribers("feed1", env);
expect(receivedSig256).toMatch(/^sha256=[0-9a-f]{64}$/);
expect(receivedSig).toBe(""); // legacy header should NOT be sent
});
it("POSTs Atom feed XML with correct Content-Type to Atom subscriber", async () => {
const env = mockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:metadata",
JSON.stringify({ emails: [] }),
);
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({
title: "Test Feed",
language: "en",
created_at: Date.now(),
}),
);
const subs: WebSubSubscription[] = [
{
callbackUrl: "https://atom-reader.example/callback",
expiresAt: Date.now() + 60000,
format: "atom",
},
];
await saveSubscriptions("feed1", subs, env);
let receivedContentType = "";
let receivedLink = "";
server.use(
http.post("https://atom-reader.example/callback", async ({ request }) => {
receivedContentType = request.headers.get("Content-Type") ?? "";
receivedLink = request.headers.get("Link") ?? "";
return HttpResponse.text("ok");
}),
);
await notifySubscribers("feed1", env);
expect(receivedContentType).toContain("application/atom+xml");
expect(receivedLink).toContain(`/atom/feed1`);
expect(receivedLink).toContain(`rel="self"`);
});
it("notifies RSS and Atom subscribers independently with correct formats", async () => {
const env = mockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:metadata",
JSON.stringify({ emails: [] }),
);
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({
title: "Test Feed",
language: "en",
created_at: Date.now(),
}),
);
const subs: WebSubSubscription[] = [
{
callbackUrl: "https://rss-reader.example/callback",
expiresAt: Date.now() + 60000,
format: "rss",
},
{
callbackUrl: "https://atom-reader.example/callback",
expiresAt: Date.now() + 60000,
format: "atom",
},
];
await saveSubscriptions("feed1", subs, env);
const received: Record<string, string> = {};
server.use(
http.post("https://rss-reader.example/callback", async ({ request }) => {
received.rss = request.headers.get("Content-Type") ?? "";
return HttpResponse.text("ok");
}),
http.post("https://atom-reader.example/callback", async ({ request }) => {
received.atom = request.headers.get("Content-Type") ?? "";
return HttpResponse.text("ok");
}),
);
await notifySubscribers("feed1", env);
expect(received.rss).toContain("application/rss+xml");
expect(received.atom).toContain("application/atom+xml");
});
it("prunes expired subscriptions and does not notify them", async () => {
const env = mockEnv();
await env.EMAIL_STORAGE.put(
"feed:feed1:metadata",
JSON.stringify({ emails: [] }),
);
await env.EMAIL_STORAGE.put(
"feed:feed1:config",
JSON.stringify({
title: "Test Feed",
language: "en",
created_at: Date.now(),
}),
);
const subs: WebSubSubscription[] = [
{
callbackUrl: "https://expired.example/callback",
expiresAt: Date.now() - 1000,
},
{
callbackUrl: "https://active.example/callback",
expiresAt: Date.now() + 60000,
},
];
await saveSubscriptions("feed1", subs, env);
const notified: string[] = [];
server.use(
http.post("https://expired.example/callback", () => {
notified.push("expired");
return HttpResponse.text("ok");
}),
http.post("https://active.example/callback", () => {
notified.push("active");
return HttpResponse.text("ok");
}),
);
await notifySubscribers("feed1", env);
expect(notified).toEqual(["active"]);
const remaining = await getSubscriptions("feed1", env);
expect(remaining).toHaveLength(1);
expect(remaining[0].callbackUrl).toBe("https://active.example/callback");
});
});
describe("verifyAndStoreSubscription", () => {
it("stores subscription and returns true when callback echoes challenge", async () => {
const env = mockEnv();
server.use(
http.get("https://reader.example/callback", ({ request }) => {
const url = new URL(request.url);
const challenge = url.searchParams.get("hub.challenge") ?? "";
return HttpResponse.text(challenge);
}),
);
const result = await verifyAndStoreSubscription(
"feed1",
"https://reader.example/callback",
undefined,
86400,
"rss",
env,
);
expect(result).toBe(true);
const subs = await getSubscriptions("feed1", env);
expect(subs).toHaveLength(1);
expect(subs[0].callbackUrl).toBe("https://reader.example/callback");
expect(subs[0].expiresAt).toBeGreaterThan(Date.now());
});
it("stores format=atom and sends atom topic URL in verification request", async () => {
const env = mockEnv();
let receivedTopic = "";
server.use(
http.get("https://reader.example/callback", ({ request }) => {
const url = new URL(request.url);
receivedTopic = url.searchParams.get("hub.topic") ?? "";
const challenge = url.searchParams.get("hub.challenge") ?? "";
return HttpResponse.text(challenge);
}),
);
const result = await verifyAndStoreSubscription(
"feed1",
"https://reader.example/callback",
undefined,
86400,
"atom",
env,
);
expect(result).toBe(true);
expect(receivedTopic).toContain("/atom/feed1");
const subs = await getSubscriptions("feed1", env);
expect(subs[0].format).toBe("atom");
});
it("returns false and does not store when callback returns wrong challenge", async () => {
const env = mockEnv();
server.use(
http.get("https://reader.example/callback", () =>
HttpResponse.text("wrong"),
),
);
const result = await verifyAndStoreSubscription(
"feed1",
"https://reader.example/callback",
undefined,
86400,
"rss",
env,
);
expect(result).toBe(false);
const subs = await getSubscriptions("feed1", env);
expect(subs).toHaveLength(0);
});
it("updates existing subscription with same callback", async () => {
const env = mockEnv();
const existing: WebSubSubscription[] = [
{ callbackUrl: "https://reader.example/callback", expiresAt: 1000 },
];
await saveSubscriptions("feed1", existing, env);
server.use(
http.get("https://reader.example/callback", ({ request }) => {
const challenge =
new URL(request.url).searchParams.get("hub.challenge") ?? "";
return HttpResponse.text(challenge);
}),
);
const result = await verifyAndStoreSubscription(
"feed1",
"https://reader.example/callback",
"newsecret",
3600,
"rss",
env,
);
expect(result).toBe(true);
const subs = await getSubscriptions("feed1", env);
expect(subs).toHaveLength(1);
expect(subs[0].secret).toBe("newsecret");
});
it("returns false when callback fetch fails", async () => {
const env = mockEnv();
server.use(
http.get("https://reader.example/callback", () => HttpResponse.error()),
);
const result = await verifyAndStoreSubscription(
"feed1",
"https://reader.example/callback",
undefined,
86400,
"rss",
env,
);
expect(result).toBe(false);
const subs = await getSubscriptions("feed1", env);
expect(subs).toHaveLength(0);
});
it("returns false when callback returns non-ok HTTP status", async () => {
const env = mockEnv();
server.use(
http.get("https://reader.example/callback", ({ request }) => {
const challenge =
new URL(request.url).searchParams.get("hub.challenge") ?? "";
return HttpResponse.text(challenge, { status: 500 });
}),
);
const result = await verifyAndStoreSubscription(
"feed1",
"https://reader.example/callback",
undefined,
86400,
"rss",
env,
);
expect(result).toBe(false);
const subs = await getSubscriptions("feed1", env);
expect(subs).toHaveLength(0);
});
});
describe("verifyAndDeleteSubscription", () => {
it("removes subscription and returns true when callback echoes challenge", async () => {
const env = mockEnv();
await saveSubscriptions(
"feed1",
[
{
callbackUrl: "https://reader.example/callback",
expiresAt: Date.now() + 60000,
},
],
env,
);
server.use(
http.get("https://reader.example/callback", ({ request }) => {
const challenge =
new URL(request.url).searchParams.get("hub.challenge") ?? "";
return HttpResponse.text(challenge);
}),
);
const result = await verifyAndDeleteSubscription(
"feed1",
"https://reader.example/callback",
env,
);
expect(result).toBe(true);
const subs = await getSubscriptions("feed1", env);
expect(subs).toHaveLength(0);
});
it("returns false and leaves subscription intact when callback returns wrong challenge", async () => {
const env = mockEnv();
await saveSubscriptions(
"feed1",
[
{
callbackUrl: "https://reader.example/callback",
expiresAt: Date.now() + 60000,
},
],
env,
);
server.use(
http.get("https://reader.example/callback", () =>
HttpResponse.text("nope"),
),
);
const result = await verifyAndDeleteSubscription(
"feed1",
"https://reader.example/callback",
env,
);
expect(result).toBe(false);
const subs = await getSubscriptions("feed1", env);
expect(subs).toHaveLength(1);
});
it("returns false and leaves subscription intact when callback fetch fails", async () => {
const env = mockEnv();
await saveSubscriptions(
"feed1",
[
{
callbackUrl: "https://reader.example/callback",
expiresAt: Date.now() + 60000,
},
],
env,
);
server.use(
http.get("https://reader.example/callback", () => HttpResponse.error()),
);
const result = await verifyAndDeleteSubscription(
"feed1",
"https://reader.example/callback",
env,
);
expect(result).toBe(false);
const subs = await getSubscriptions("feed1", env);
expect(subs).toHaveLength(1);
});
});
+245
View File
@@ -0,0 +1,245 @@
import {
Env,
FeedConfig,
FeedMetadata,
EmailData,
WebSubSubscription,
} from "../types";
import { generateRssFeed, generateAtomFeed } from "./feed-generator";
import { baseUrl, feedRssUrl, feedAtomUrl, feedUrl } from "./urls";
const KV_PREFIX = "websub:subs:";
export function subscriptionKey(feedId: string): string {
return `${KV_PREFIX}${feedId}`;
}
export async function getSubscriptions(
feedId: string,
env: Env,
): Promise<WebSubSubscription[]> {
const raw = await env.EMAIL_STORAGE.get(subscriptionKey(feedId), "json");
return (raw as WebSubSubscription[] | null) ?? [];
}
export async function saveSubscriptions(
feedId: string,
subscriptions: WebSubSubscription[],
env: Env,
): Promise<void> {
await env.EMAIL_STORAGE.put(
subscriptionKey(feedId),
JSON.stringify(subscriptions),
);
}
export async function buildHmacSignature(
body: string,
secret: string,
): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(body),
);
const hex = Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `sha256=${hex}`;
}
async function buildFeedXml(
feedId: string,
env: Env,
format: "rss" | "atom" = "rss",
): Promise<string | null> {
const [rawMetadata, rawConfig] = await Promise.all([
env.EMAIL_STORAGE.get(`feed:${feedId}:metadata`, "json"),
env.EMAIL_STORAGE.get(`feed:${feedId}:config`, "json"),
]);
const feedMetadata = rawMetadata as FeedMetadata | null;
if (!feedMetadata) return null;
const base = baseUrl(env);
const feedConfig = (rawConfig as FeedConfig | null) ?? {
title: `Newsletter Feed ${feedId}`,
description: "Converted email newsletter",
language: "en",
created_at: Date.now(),
};
const emails = feedMetadata.emails.slice(0, 20);
const emailsData = (
await Promise.all(
emails.map(
(m) =>
env.EMAIL_STORAGE.get(m.key, "json") as Promise<EmailData | null>,
),
)
).filter((d): d is EmailData => d !== null);
if (format === "atom") {
return generateAtomFeed(
feedConfig,
emailsData,
base,
feedId,
feedAtomUrl(feedId, env),
);
}
return generateRssFeed(feedConfig, emailsData, base, feedId);
}
export async function notifySubscribers(
feedId: string,
env: Env,
): Promise<void> {
const subs = await getSubscriptions(feedId, env);
const now = Date.now();
const active = subs.filter((s) => s.expiresAt > now);
if (active.length === 0) {
if (active.length < subs.length) {
await saveSubscriptions(feedId, active, env);
}
return;
}
const rssSubs = active.filter((s) => (s.format ?? "rss") === "rss");
const atomSubs = active.filter((s) => s.format === "atom");
const [rssFeed, atomFeed] = await Promise.all([
rssSubs.length > 0 ? buildFeedXml(feedId, env, "rss") : null,
atomSubs.length > 0 ? buildFeedXml(feedId, env, "atom") : null,
]);
if (!rssFeed && !atomFeed) return;
const base = baseUrl(env);
const deliver = async (
sub: WebSubSubscription,
feedXml: string,
contentType: string,
selfPath: string,
) => {
const linkHeader = `<${base}/hub>; rel="hub", <${base}${selfPath}>; rel="self"`;
const headers: Record<string, string> = {
"Content-Type": contentType,
Link: linkHeader,
};
if (sub.secret) {
headers["X-Hub-Signature-256"] = await buildHmacSignature(
feedXml,
sub.secret,
);
}
const res = await fetch(sub.callbackUrl, {
method: "POST",
headers,
body: feedXml,
});
if (!res.ok) {
console.error(
`WebSub: delivery failed ${sub.callbackUrl}: ${res.status}`,
);
}
};
await Promise.allSettled([
...(rssFeed
? rssSubs.map((sub) =>
deliver(sub, rssFeed, "application/rss+xml", `/rss/${feedId}`),
)
: []),
...(atomFeed
? atomSubs.map((sub) =>
deliver(sub, atomFeed, "application/atom+xml", `/atom/${feedId}`),
)
: []),
]);
if (active.length < subs.length) {
await saveSubscriptions(feedId, active, env);
}
}
async function verifyCallback(
callbackUrl: string,
params: Record<string, string>,
): Promise<boolean> {
const challenge = crypto.randomUUID().replace(/-/g, "");
const url = new URL(callbackUrl);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
url.searchParams.set("hub.challenge", challenge);
let res: Response;
try {
res = await fetch(url.toString());
} catch {
return false;
}
if (!res.ok) return false;
return (await res.text()).trim() === challenge;
}
export async function verifyAndStoreSubscription(
feedId: string,
callbackUrl: string,
secret: string | undefined,
leaseSeconds: number,
format: "rss" | "atom",
env: Env,
): Promise<boolean> {
const verified = await verifyCallback(callbackUrl, {
"hub.mode": "subscribe",
"hub.topic": feedUrl(format, feedId, env),
"hub.lease_seconds": String(leaseSeconds),
});
if (!verified) return false;
const subs = await getSubscriptions(feedId, env);
const idx = subs.findIndex((s) => s.callbackUrl === callbackUrl);
const entry: WebSubSubscription = {
callbackUrl,
expiresAt: Date.now() + leaseSeconds * 1000,
format,
...(secret ? { secret } : {}),
};
if (idx >= 0) {
subs[idx] = entry;
} else {
subs.push(entry);
}
await saveSubscriptions(feedId, subs, env);
return true;
}
export async function verifyAndDeleteSubscription(
feedId: string,
callbackUrl: string,
env: Env,
): Promise<boolean> {
const verified = await verifyCallback(callbackUrl, {
"hub.mode": "unsubscribe",
"hub.topic": feedRssUrl(feedId, env),
});
if (!verified) return false;
const subs = await getSubscriptions(feedId, env);
await saveSubscriptions(
feedId,
subs.filter((s) => s.callbackUrl !== callbackUrl),
env,
);
return true;
}
+10
View File
@@ -0,0 +1,10 @@
import { Context } from "hono";
/** Calls ctx.waitUntil() without throwing when the ExecutionContext is absent (e.g. Node tests). */
export function waitUntilSafe(c: Context, promise: Promise<unknown>): void {
try {
c.executionCtx.waitUntil(promise);
} catch {
// ExecutionContext unavailable in Node test environment — ignore.
}
}
+9 -6
View File
@@ -1,18 +1,21 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2021",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "node", "moduleResolution": "bundler",
"esModuleInterop": true, "esModuleInterop": true,
"strict": true, "strict": true,
"lib": ["ES2020"], "lib": ["ES2021"],
"types": ["@cloudflare/workers-types"], "types": ["@cloudflare/workers-types"],
"outDir": "dist", "outDir": "dist",
"noEmit": true, "noEmit": true,
"isolatedModules": true, "isolatedModules": true,
"allowJs": true, "allowJs": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist", "src/scripts/client/**/*"]
} }
+58 -2
View File
@@ -1,4 +1,4 @@
name = "email-to-rss" name = "kill-the-news"
main = "src/index.ts" main = "src/index.ts"
compatibility_date = "REPLACE_WITH_COMPATIBILITY_DATE" compatibility_date = "REPLACE_WITH_COMPATIBILITY_DATE"
compatibility_flags = ["nodejs_compat"] compatibility_flags = ["nodejs_compat"]
@@ -8,6 +8,17 @@ kv_namespaces = [
{ binding = "EMAIL_STORAGE", id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID", preview_id = "REPLACE_WITH_YOUR_PREVIEW_KV_NAMESPACE_ID" } { binding = "EMAIL_STORAGE", id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID", preview_id = "REPLACE_WITH_YOUR_PREVIEW_KV_NAMESPACE_ID" }
] ]
# Optional: R2 bucket for storing email attachment enclosures
# r2_buckets = [
# { binding = "ATTACHMENT_BUCKET", bucket_name = "REPLACE_WITH_YOUR_BUCKET_NAME", preview_bucket_name = "REPLACE_WITH_YOUR_PREVIEW_BUCKET_NAME" }
# ]
# Import CSS files as text strings (inlined in HTML <style> tags)
[[rules]]
type = "Text"
globs = ["**/*.css"]
fallthrough = false
# Workers Observability (keeps config in sync with dashboard toggle) # Workers Observability (keeps config in sync with dashboard toggle)
[observability.logs] [observability.logs]
enabled = true enabled = true
@@ -15,7 +26,24 @@ invocation_logs = true
# Global Environment variables # Global Environment variables
[vars] [vars]
DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Your custom domain for emails DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Web domain (used for feed URLs and admin UI)
# Optional: email domain when it differs from the web domain
# Example: DOMAIN = "demo.example.com" but emails are @example.com
# EMAIL_DOMAIN = "REPLACE_WITH_YOUR_EMAIL_DOMAIN"
# Optional: size-based feed trimming threshold in bytes (default: 524288 = 512 KB)
# FEED_MAX_SIZE_BYTES = "524288"
# Optional: external proxy auth (Authelia/Authentik)
# Comma-separated IPs of trusted reverse proxies
# PROXY_TRUSTED_IPS = "10.0.0.1"
# ── Worker secrets (never put these in [vars]) ──────────────────────────────
# PROXY_AUTH_SECRET must be set as a Worker secret, NOT in [vars]:
# wrangler secret put PROXY_AUTH_SECRET
# WARNING: never add it to [vars] — it would be committed to version control.
# WARNING: disable the workers.dev subdomain in production when using proxy auth.
# Development environment # Development environment
[env.dev] [env.dev]
@@ -29,6 +57,11 @@ kv_namespaces = [
{ binding = "EMAIL_STORAGE", id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" } { binding = "EMAIL_STORAGE", id = "REPLACE_WITH_YOUR_KV_NAMESPACE_ID" }
] ]
# Optional: R2 bucket for storing email attachment enclosures
# r2_buckets = [
# { binding = "ATTACHMENT_BUCKET", bucket_name = "REPLACE_WITH_YOUR_BUCKET_NAME" }
# ]
routes = [ routes = [
{ pattern = "REPLACE_WITH_YOUR_DOMAIN", custom_domain = true }, { pattern = "REPLACE_WITH_YOUR_DOMAIN", custom_domain = true },
{ pattern = "www.REPLACE_WITH_YOUR_DOMAIN", custom_domain = true } { pattern = "www.REPLACE_WITH_YOUR_DOMAIN", custom_domain = true }
@@ -36,3 +69,26 @@ routes = [
[env.production.vars] [env.production.vars]
DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" DOMAIN = "REPLACE_WITH_YOUR_DOMAIN"
# Demo environment (auto-deployed from main via GitHub Actions)
# Accessible at https://demo.kill-the.news
# Emails received at *@kill-the.news are routed here via Cloudflare Email Routing
[env.demo]
name = "kill-the-news-demo"
workers_dev = false
kv_namespaces = [
{ binding = "EMAIL_STORAGE", id = "REPLACE_WITH_DEMO_KV_NAMESPACE_ID" }
]
routes = [
{ pattern = "demo.kill-the.news", custom_domain = true }
]
[env.demo.vars]
DOMAIN = "demo.kill-the.news"
EMAIL_DOMAIN = "kill-the.news" # Optional: email domain when it differs from the web domain
# Nightly reset: wipe all KV data at 03:00 UTC so the demo stays clean
[env.demo.triggers]
crons = ["0 3 * * *"]
+13
View File
@@ -0,0 +1,13 @@
name = "kill-the-news"
main = "src/index.ts"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
kv_namespaces = [
{ binding = "EMAIL_STORAGE", id = "00000000000000000000000000000000" }
]
[[rules]]
type = "Text"
globs = ["**/*.css"]
fallthrough = false