Compare commits
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.
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
github: [juherr]
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -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
|
||||||
@@ -4,8 +4,10 @@ yarn.lock
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
dist/
|
dist/
|
||||||
|
coverage/
|
||||||
.wrangler/
|
.wrangler/
|
||||||
.dev.vars
|
.dev.vars
|
||||||
|
src/scripts/generated/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
npx lint-staged && npm run typecheck && npm test
|
||||||
@@ -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`
|
|
||||||
@@ -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`
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
kill-the.news
|
||||||
+1068
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
Generated
+3037
-825
File diff suppressed because it is too large.
Load diff
+42
-15
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,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..."
|
||||||
|
|||||||
@@ -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";
|
||||||
+575
-75
@@ -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",
|
||||||
];
|
];
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
+105
-60
@@ -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: {
|
||||||
@@ -32,9 +41,9 @@ async function getForwardEmailIps(): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -53,94 +62,130 @@ async function getForwardEmailIps(): Promise<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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||||
@@ -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, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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()}...`;
|
||||||
|
}
|
||||||
@@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
+30
-55
@@ -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"`,
|
||||||
|
`<${feedRssUrl(feedId, c.env)}>; rel="self"`,
|
||||||
|
].join(", ");
|
||||||
|
|
||||||
// Get the emails for this feed (up to the last 20)
|
|
||||||
const emails = feedMetadata.emails.slice(0, 20);
|
|
||||||
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2021", "DOM"],
|
||||||
|
"types": [],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["./**/*"],
|
||||||
|
"exclude": []
|
||||||
|
}
|
||||||
@@ -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
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "*.css" {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
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
@@ -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" },
|
||||||
|
|||||||
Vendored
+6
-3
@@ -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,7 +13,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+3
-3
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-65
@@ -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., [email protected])
|
|
||||||
* @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
|
||||||
|
? `${payload.from.value[0].name || ""} <${payload.from.value[0].address}>`
|
||||||
|
: "Unknown Sender");
|
||||||
|
|
||||||
// Extract the sender information using ForwardEmail's structure
|
const subject = this.decodeEncodedWords(payload.subject || "No Subject");
|
||||||
const fromAddress = payload.from?.text ||
|
const content = payload.html || payload.text || "";
|
||||||
(payload.from?.value?.[0]?.address ?
|
|
||||||
`${payload.from.value[0].name || ''} <${payload.from.value[0].address}>` :
|
|
||||||
'Unknown Sender');
|
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -78,35 +55,26 @@ 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));
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
+86
-34
@@ -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*$/);
|
||||||
|
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,
|
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();
|
||||||
}
|
}
|
||||||
@@ -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("<");
|
||||||
|
expect(result).toContain(">");
|
||||||
|
expect(result).toContain("&");
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+50
-26
@@ -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,7 +13,7 @@ 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();
|
||||||
@@ -19,7 +26,7 @@ export async function storeEmail(
|
|||||||
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,7 +105,7 @@ 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`;
|
||||||
@@ -96,9 +113,12 @@ export async function createFeed(
|
|||||||
|
|
||||||
// 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,17 +131,19 @@ 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: [] };
|
||||||
}
|
}
|
||||||
@@ -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)/([^/]+)$`);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-5
@@ -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
@@ -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 * * *"]
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user