mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 06:13:48 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b002f8ad43 | |||
| 5137637181 | |||
| be45e70571 | |||
| 06c436c36a | |||
| d68a24867d | |||
| ad196f1761 | |||
| b3d42f6c50 | |||
| 46af982c40 | |||
| f823a5f222 | |||
| 23dd0a0c96 | |||
| e324571122 | |||
| 7bf0f71f86 | |||
| ab1c15e69a | |||
| 05388b45c8 | |||
| c45f6677fe | |||
| a31ff42f59 | |||
| b347f2f625 | |||
| 49f69ff19e | |||
| c65aabe7f4 | |||
| 8f036cf223 | |||
| 6b51173722 | |||
| 2b3f00f7e3 | |||
| a0eaebe749 | |||
| c2a0a68058 | |||
| daa93d8093 | |||
| 45d2a14a12 | |||
| 7f5b913576 | |||
| db31e33a8b | |||
| 8ffa4ad5e7 | |||
| 7f4afa3ec8 | |||
| 3368b0d1d2 | |||
| 5fc91a0be4 | |||
| debbfc623e | |||
| 6cd2d425a2 | |||
| 9141cf89bd | |||
| ddde0e26a2 | |||
| 20c9bca34a | |||
| 2de09b2a5d | |||
| f150d40c45 | |||
| 7226e718f7 | |||
| f4e751e40b | |||
| 766f2717a7 | |||
| d322bc1e92 | |||
| 3ad0188bc0 | |||
| eb12f21894 | |||
| d299c8891d | |||
| a67baa71f4 | |||
| b985e2c643 | |||
| 81d05e5774 | |||
| 4db9fc1b8a | |||
| 6bfaa4dec7 | |||
| fd6a1a945f | |||
| d1959acad1 | |||
| b534ce5bf8 | |||
| f4d5edda0e |
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "docs",
|
||||||
|
"runtimeExecutable": "npx",
|
||||||
|
"runtimeArgs": ["serve", "docs", "-p", "4321", "--no-clipboard"],
|
||||||
|
"port": 4321
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 8787
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dev-build",
|
||||||
|
"runtimeExecutable": "npx",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"wrangler",
|
||||||
|
"dev",
|
||||||
|
"--config",
|
||||||
|
"wrangler.build.toml",
|
||||||
|
"--port",
|
||||||
|
"8788"
|
||||||
|
],
|
||||||
|
"port": 8788
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -32,13 +32,20 @@ Single Cloudflare Worker built with Hono. Routes:
|
|||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
| ------------------------------------ | ---------------------------------------------------------------------- | ------- |
|
| ------------------------------------ | ---------------------------------------------------------------------- | ------- |
|
||||||
|
| `GET /` | Public status page (monitoring counters + link to admin) |
|
||||||
| `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources |
|
| `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources |
|
||||||
|
| `/api/v1/feeds*` | Versioned REST API (Bearer/proxy auth) — feeds + emails CRUD |
|
||||||
|
| `GET /api/v1/stats` | Public monitoring counters (JSON, CORS); canonical stats endpoint |
|
||||||
|
| `GET /api/openapi.json` | OpenAPI 3.1 spec (public) |
|
||||||
|
| `GET /api/docs` | Rendered API reference (Scalar, public) |
|
||||||
| `GET /rss/:feedId` | Public RSS 2.0 feed |
|
| `GET /rss/:feedId` | Public RSS 2.0 feed |
|
||||||
| `GET /atom/:feedId` | Public Atom feed (with WebSub hub header) |
|
| `GET /atom/:feedId` | Public Atom feed (with WebSub hub header) |
|
||||||
| `GET /entries/:feedId/:entryId` | Individual email HTML view |
|
| `GET /entries/:feedId/:entryId` | Individual email HTML view |
|
||||||
| `GET /files/:attachmentId/:filename` | R2 attachment serving |
|
| `GET /files/:attachmentId/:filename` | R2 attachment serving |
|
||||||
| `GET /admin` | Password-protected admin UI |
|
| `GET /admin` | Password-protected admin UI |
|
||||||
| `/hub` | WebSub hub (subscribe/publish) |
|
| `/hub` | WebSub hub (subscribe/publish) |
|
||||||
|
| `GET /favicon.svg`, `/favicon.ico` | Project favicon (envelope logo); fallback for per-feed favicons |
|
||||||
|
| `GET /favicon/:feedId` | Per-feed favicon from the last sender's domain (falls back to project) |
|
||||||
| `GET /health` | Health check |
|
| `GET /health` | Health check |
|
||||||
| `email` | Cloudflare Email routing handler (alternative to ForwardEmail webhook) |
|
| `email` | Cloudflare Email routing handler (alternative to ForwardEmail webhook) |
|
||||||
|
|
||||||
@@ -49,6 +56,41 @@ src/
|
|||||||
index.ts # App entrypoint: CORS, IP middleware, route mounting, email handler export
|
index.ts # App entrypoint: CORS, IP middleware, route mounting, email handler export
|
||||||
config/constants.ts # Shared constants (TTLs, limits)
|
config/constants.ts # Shared constants (TTLs, limits)
|
||||||
types/index.ts # Env, FeedConfig, EmailData, WebSubSubscription, etc.
|
types/index.ts # Env, FeedConfig, EmailData, WebSubSubscription, etc.
|
||||||
|
domain/ # Framework-agnostic core (no Hono/infra imports leak out)
|
||||||
|
feed.aggregate.ts # Feed aggregate: consistency boundary; holds domain FeedState (camelCase), exposes intention-revealing reads, never raw state/metadata
|
||||||
|
feed-state.ts # FeedState: the aggregate's config in domain (camelCase) vocabulary — NOT the snake_case persistence DTO
|
||||||
|
feed.ts # The expiry predicate (`isExpired`) — the one invariant shared with the read-model routes
|
||||||
|
feed-keys.ts # The KV key schema (pure string builders), shared by every repository
|
||||||
|
clock.ts # Clock port (systemClock) — injected into the aggregate; no ambient Date.now()
|
||||||
|
events.ts # FeedEvent union (FeedCreated, EmailIngested) — each carries its feedId
|
||||||
|
email-parser.ts # Email parsing (addresses, headers, encoded words)
|
||||||
|
format.ts # Pure formatting helpers (formatBytes)
|
||||||
|
value-objects/ # FeedId, EmailAddress, Domain, SenderPolicy, Lifetime (immutable, self-validating)
|
||||||
|
application/ # Use-cases / orchestration (wires domain + infrastructure)
|
||||||
|
feed-service.ts # createFeedRecord / editFeedDetails / editFeed / deleteFeedRecord (admin UI + REST API)
|
||||||
|
feed-cleanup.ts # Feed/email storage cleanup: purgeFeedKeysStep, collectUnsubscribeUrls, attachment+key deletion
|
||||||
|
feed-events.ts # Dispatcher: maps aggregate FeedEvents to side effects (counters, WebSub, favicon)
|
||||||
|
email-processor.ts # Core ingestion: load aggregate → accepts? → feed.ingest → persist
|
||||||
|
feed-fetcher.ts # Read model for RSS/Atom rendering (config + email bodies; bypasses the aggregate)
|
||||||
|
stats.ts # Monitoring counters increment policy + storage scans
|
||||||
|
infrastructure/ # Adapters: KV/R2, outbound HTTP, logging, framework glue
|
||||||
|
logger.ts # JSON structured logger
|
||||||
|
feed-repository.ts # KV adapter for the Feed aggregate + global feed list + email bodies (load/save)
|
||||||
|
feed-mapper.ts # Translation seam: domain FeedState ↔ persistence DTOs (FeedConfig/FeedListItem); sole owner of snake_case outside the edge
|
||||||
|
icon-repository.ts # KV adapter for cached favicons (icon:*)
|
||||||
|
websub-subscription-repository.ts # KV adapter for WebSub subscriber lists (websub:subs:*)
|
||||||
|
counters-repository.ts # KV adapter for the monitoring counters singleton (stats:counters)
|
||||||
|
auth.ts # timingSafeEqual, proxy-auth check, API bearer middleware
|
||||||
|
cloudflare-email.ts # Cloudflare Email routing handler
|
||||||
|
forwardemail.ts # ForwardEmail webhook types/parsing
|
||||||
|
worker.ts # Typed worker / waitUntil helper
|
||||||
|
attachments.ts # R2 bucket accessor
|
||||||
|
favicon-fetcher.ts # Outbound favicon fetch + cache (uses IconRepository)
|
||||||
|
feed-generator.ts # RSS/Atom XML generation
|
||||||
|
html-processor.ts # Email HTML sanitization / inline cid: rewriting
|
||||||
|
websub.ts # WebSub subscription management + delivery
|
||||||
|
unsubscribe.ts # RFC 8058 one-click unsubscribe dispatch
|
||||||
|
urls.ts # URL builders
|
||||||
routes/
|
routes/
|
||||||
inbound.ts # ForwardEmail webhook handler
|
inbound.ts # ForwardEmail webhook handler
|
||||||
rss.ts # RSS feed renderer
|
rss.ts # RSS feed renderer
|
||||||
@@ -56,24 +98,16 @@ src/
|
|||||||
entries.ts # Single email HTML view
|
entries.ts # Single email HTML view
|
||||||
files.ts # R2 attachment serving
|
files.ts # R2 attachment serving
|
||||||
hub.ts # WebSub hub
|
hub.ts # WebSub hub
|
||||||
|
home.tsx # Public status page (GET /)
|
||||||
admin.tsx # Admin UI entrypoint (hono/jsx)
|
admin.tsx # Admin UI entrypoint (hono/jsx)
|
||||||
admin/ # Admin sub-modules
|
admin/ # Admin sub-modules
|
||||||
feeds.tsx # Feeds CRUD UI
|
feeds.tsx # Feeds CRUD UI
|
||||||
emails.tsx # Emails list/delete UI
|
emails.tsx # Emails list/delete UI
|
||||||
ui.tsx # Shared UI components
|
ui.tsx # Shared UI components
|
||||||
helpers.ts # Shared admin helpers
|
helpers.ts # Shared admin helpers
|
||||||
lib/
|
api/ # Versioned REST API (@hono/zod-openapi)
|
||||||
cloudflare-email.ts # Cloudflare Email routing handler
|
index.ts # OpenAPIHono app: /v1 routes + /openapi.json + /docs
|
||||||
email-parser.ts # Email parsing (mailparser)
|
schemas.ts # Zod schemas (validation + OpenAPI source of truth)
|
||||||
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/
|
scripts/
|
||||||
client/ # TypeScript client scripts (compiled by esbuild)
|
client/ # TypeScript client scripts (compiled by esbuild)
|
||||||
dashboard.ts # Admin dashboard interactions
|
dashboard.ts # Admin dashboard interactions
|
||||||
@@ -93,14 +127,29 @@ src/
|
|||||||
All data lives in the `EMAIL_STORAGE` KV namespace:
|
All data lives in the `EMAIL_STORAGE` KV namespace:
|
||||||
|
|
||||||
| Key | Value |
|
| Key | Value |
|
||||||
| -------------------------------- | ------------------------------------------------------------------------ |
|
| --------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
| `feeds:list` | `{ feeds: Array<{ id, title, description? }> }` |
|
| `feeds:list` | `{ feeds: Array<{ id, title, description?, expires_at? }> }` |
|
||||||
| `feed:<feedId>:config` | `FeedConfig` |
|
| `feed:<feedId>:config` | `FeedConfig` |
|
||||||
| `feed:<feedId>:metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds? }> }` |
|
| `feed:<feedId>:metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds?, inlineAttachmentIds? }> }` |
|
||||||
| `feed:<feedId>:<timestamp>` | Full `EmailData` |
|
| `feed:<feedId>:<timestamp>` | Full `EmailData` |
|
||||||
| `websub:<feedId>:<callbackHash>` | `WebSubSubscription` |
|
| `websub:subs:<feedId>` | `WebSubSubscription[]` (per-feed subscriber list) |
|
||||||
|
| `icon:<domain>` | Cached favicon record (base64 + content type; negative entries allowed) |
|
||||||
|
| `stats:counters` | `Counters` (cumulative monitoring counters singleton) |
|
||||||
|
|
||||||
`src/lib/storage.ts` contains key-builder helpers — use them; don't inline key strings in routes.
|
The KV key schema lives in `src/domain/feed-keys.ts` (pure, framework-agnostic) — never inline a `feed:`/`feeds:list`/`websub:`/`icon:`/`stats:counters` key string anywhere else. KV access is owned by four repository **adapters** in `src/infrastructure/`, each for one concern: `FeedRepository` (the Feed aggregate + global list + email bodies), `IconRepository` (`icon:*`), `WebSubSubscriptionRepository` (`websub:subs:*`), and `CountersRepository` (`stats:counters`). Go through a repository, never `env.EMAIL_STORAGE.get/put` directly. The domain depends only on the key schema, not on these adapters.
|
||||||
|
|
||||||
|
### Domain & layering rules
|
||||||
|
|
||||||
|
- **Layers**: `domain/` is framework-agnostic (no Hono). `application/` orchestrates use-cases. `infrastructure/` holds adapters (KV/R2, HTTP, logging). `routes/` is the HTTP edge. Imports point inward: routes → application → domain; infrastructure implements ports the inner layers call.
|
||||||
|
- **The `Feed` aggregate is the only writer of feed config + the email index.** Load it with `FeedRepository.load(feedId)`, mutate via its methods (`ingest`, `removeEmails`, `edit`), then persist with `save`/`saveMetadata`/`saveConfig`. No route or service mutates `metadata.emails` directly. Email **bodies** are large blobs outside the aggregate — flush them (`putEmail`/`deleteEmail`) alongside the metadata save.
|
||||||
|
- **The domain never speaks the storage dialect.** The aggregate holds its config as domain `FeedState` (camelCase), never the snake_case `FeedConfig` DTO. The translation `FeedState ↔ FeedConfig/FeedListItem` lives in `infrastructure/feed-mapper.ts` — the only place outside the HTTP edge that knows the persisted field names. `FeedRepository.load` maps DTO→state on the way in; `save`/`saveConfig` map state→DTO on the way out.
|
||||||
|
- **The aggregate never exposes its raw state.** It has no `state`/`metadata` getters (a shallow `Readonly<…>` would still leak mutable arrays). Read named accessors (`title`, `expiresAt`, `emails`, `allowedSenders()`, …) which return copies; the repository reads `state()`/`toMetadataSnapshot()` (copies) and runs them through the mapper.
|
||||||
|
- **One edit path.** `edit(patch, { lifetime? })` is the single mutation for config. A `Lifetime` VO is resolved by the application (env `FEED_TTL_HOURS` override + client request); its **presence recomputes expiry, its absence preserves it** — which is exactly the dashboard's title/description quick-edit (no lifetime passed). It rejects an already-expired feed, so a quick-edit can no more touch an expired feed than a full edit can.
|
||||||
|
- **`feeds:list` stays in sync automatically.** `FeedRepository.save`/`saveConfig` upsert the registry entry via `toListItemDTO(feed.id, feed.state())` — services never mirror title/description/expiry into the list by hand.
|
||||||
|
- Read-only RSS/Atom rendering uses the `feed-fetcher` read model, not the aggregate (no invariant to enforce on the hot path).
|
||||||
|
- KV has no multi-key transaction; the aggregate is the seam a future Durable Object would wrap to serialise concurrent ingests (see `email-processor.ts`).
|
||||||
|
- **Side effects via domain events.** Mutations with consequences record a `FeedEvent` (`FeedCreated`, `EmailIngested`), each carrying its own `feedId`. After persisting, the caller hands the aggregate to `application/feed-events.dispatchFeedEvents(feed, env, schedule)` — the single dispatch entry point that drains `pullEvents()` and runs the counters/WebSub/favicon. Don't pull events or thread the feed id by hand at call sites. Side effects with no aggregate mutation (a rejected email, feed deletion that bypasses the aggregate, bulk admin ops, the cron) stay imperative — they have no event to ride on.
|
||||||
|
- **`FeedId` flows through the layers.** It is the identity type taken by the domain (`Feed.id`), the application use-cases (`editFeed`, `editFeedDetails`, `deleteFeedRecord`, `fetchFeedData`, the cleanup steps) and the infrastructure repositories/services (`FeedRepository`, `WebSubSubscriptionRepository`, `notifySubscribers`, …). Mint it **once** at the edge — `FeedId.parse(address)` for inbound email (validates), `FeedId.unchecked(param)` at the HTTP edge (no revalidation: a bad id just misses in KV and 404s), `FeedId.generate()` for a new feed — then pass the VO inward. Unwrap to `.value` (string) only at the true serialisation edges: URL builders (`urls.ts`), XML generation (`feed-generator.ts`), the KV key schema (`feed-keys.ts`), logs and JSON responses.
|
||||||
|
|
||||||
### Worker bindings (`Env`)
|
### Worker bindings (`Env`)
|
||||||
|
|
||||||
@@ -139,6 +188,6 @@ MSW (`msw/node`) handles external HTTP mocks. Tests that hit validation paths in
|
|||||||
Update together:
|
Update together:
|
||||||
|
|
||||||
- `README.md`
|
- `README.md`
|
||||||
- `AGENTS.md`
|
- `INSTALL.md` (setup, deployment, and configuration guide)
|
||||||
- `setup.sh` (if setup/deploy assumptions changed)
|
- `setup.sh` (if setup/deploy assumptions changed)
|
||||||
- Tests under `src/routes/*.test.ts` and `src/test/setup.ts`
|
- Tests under `src/routes/*.test.ts` and `src/test/setup.ts`
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Contributing to kill-the-news
|
||||||
|
|
||||||
|
Thanks for your interest in contributing! This is a small, self-hosted
|
||||||
|
Cloudflare Worker project. Issues, bug fixes, and well-scoped features are all
|
||||||
|
welcome.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
Requirements: Node.js (LTS) and npm. A Cloudflare account is only needed to
|
||||||
|
deploy, not to run tests locally.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/juherr/kill-the-news.git
|
||||||
|
cd kill-the-news
|
||||||
|
npm install # installs deps and builds client scripts (prepare hook)
|
||||||
|
npm run dev # start the local dev server (wrangler dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
For full setup, deployment, and configuration details, see
|
||||||
|
[INSTALL.md](INSTALL.md).
|
||||||
|
|
||||||
|
## Development workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # run all tests once
|
||||||
|
npm run test:watch # run tests in watch mode
|
||||||
|
npm run build # dry-run deploy bundle (wrangler deploy --dry-run)
|
||||||
|
npm run format # format with Prettier
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a single test file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/routes/admin.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Client-side scripts live in `src/scripts/client/` and are compiled by esbuild
|
||||||
|
into `src/scripts/generated/` (gitignored). They rebuild on `npm install`; to
|
||||||
|
rebuild manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:client
|
||||||
|
```
|
||||||
|
|
||||||
|
The architecture, source layout, and KV schema are documented in
|
||||||
|
[CLAUDE.md](CLAUDE.md) — a good orientation before making changes.
|
||||||
|
|
||||||
|
## Before opening a pull request
|
||||||
|
|
||||||
|
- **Add or update tests** for any behavior change. Tests live in
|
||||||
|
`src/routes/*.test.ts`, with shared mocks in `src/test/setup.ts`.
|
||||||
|
- **Run the checks**: `npm test`, `npm run build`, and `npm run format`.
|
||||||
|
Pre-commit hooks (husky + lint-staged) run lint/format on staged files.
|
||||||
|
- **Keep docs in sync.** When you change behavior, update the relevant files
|
||||||
|
together:
|
||||||
|
- `README.md`
|
||||||
|
- `INSTALL.md` (setup, deployment, configuration)
|
||||||
|
- `setup.sh` (if setup/deploy assumptions changed)
|
||||||
|
- **Keep PRs focused.** One logical change per PR is easier to review.
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
This project follows [Conventional Commits](https://www.conventionalcommits.org/)
|
||||||
|
with a scope, matching the existing history. Examples:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(admin): collapse create-feed form into accordion
|
||||||
|
fix(attachments): render inline cid: images in emails and feeds
|
||||||
|
refactor(home): dedupe byte formatting in storage cards
|
||||||
|
docs(readme): add Continuous deployment section
|
||||||
|
```
|
||||||
|
|
||||||
|
Common types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`.
|
||||||
|
|
||||||
|
## Reporting bugs and requesting features
|
||||||
|
|
||||||
|
Open an issue at
|
||||||
|
[github.com/juherr/kill-the-news/issues](https://github.com/juherr/kill-the-news/issues).
|
||||||
|
For bugs, include reproduction steps, expected vs. actual behavior, and your
|
||||||
|
environment (ingestion method, relevant config). For security issues, follow
|
||||||
|
[SECURITY.md](SECURITY.md) instead of opening a public issue.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
By contributing, you agree that your contributions will be licensed under the
|
||||||
|
[MIT License](LICENSE).
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
# Installation & deployment
|
||||||
|
|
||||||
|
How to set up, run, deploy, and configure kill-the-news. For an overview of what the project does, see [README.md](README.md).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- A Cloudflare account (free plan works — Workers, KV, and Email Routing are all included)
|
||||||
|
- A domain added to Cloudflare as a zone (DNS managed by Cloudflare)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
1. Clone this repository.
|
||||||
|
2. Authenticate Wrangler:
|
||||||
|
```bash
|
||||||
|
npx wrangler login
|
||||||
|
```
|
||||||
|
3. Run setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
4. Configure email ingestion — choose **one** of the two options below.
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
| ---- | ---- | ---------------------------------------------------- | ----------------------- |
|
||||||
|
| MX | @ | `mx1.forwardemail.net` | Priority `10`, DNS only |
|
||||||
|
| MX | @ | `mx2.forwardemail.net` | Priority `10`, DNS only |
|
||||||
|
| TXT | @ | `"forward-email=https://yourdomain.com/api/inbound"` | webhook target |
|
||||||
|
| 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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
|
||||||
|
> **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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Continuous deployment (GitHub Actions)
|
||||||
|
|
||||||
|
The repo ships a [`Deploy Demo`](.github/workflows/demo.yml) workflow that generates `wrangler.toml` from `wrangler-example.toml` and runs `wrangler deploy --env demo` after CI passes on `main`. To wire up your own automated deploys, set these repository secrets (_Settings → Secrets and variables → Actions_):
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
| ----------------------- | ------------------------------------------------------------------- |
|
||||||
|
| `CLOUDFLARE_API_TOKEN` | Scoped API token used by Wrangler to deploy (see permissions below) |
|
||||||
|
| `CLOUDFLARE_ACCOUNT_ID` | Target Cloudflare account ID |
|
||||||
|
| `DEMO_KV_NAMESPACE_ID` | KV namespace ID substituted into the generated `wrangler.toml` |
|
||||||
|
| `DEMO_ADMIN_PASSWORD` | Admin password set via `wrangler secret put` |
|
||||||
|
|
||||||
|
### Deploy token permissions
|
||||||
|
|
||||||
|
Local `npx wrangler login` uses OAuth and already has every permission, so the gaps below only bite **scoped API tokens** (i.e. CI). Create the token at <https://dash.cloudflare.com/profile/api-tokens> — the **"Edit Cloudflare Workers"** template is the easiest base — and make sure it carries the permissions matching the bindings you actually deploy:
|
||||||
|
|
||||||
|
| Permission | Needed for |
|
||||||
|
| ------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||||
|
| Account · **Workers Scripts** · Edit | Deploying the Worker and running `wrangler secret put` |
|
||||||
|
| Account · **Workers KV Storage** · Edit | The `EMAIL_STORAGE` KV binding |
|
||||||
|
| Account · **Workers R2 Storage** · Edit | The `ATTACHMENT_BUCKET` R2 binding (only when attachments are enabled) |
|
||||||
|
| Zone · **Workers Routes** · Edit + **DNS** · Edit | The `custom_domain` routes (e.g. `demo.kill-the.news`), scoped to its zone |
|
||||||
|
|
||||||
|
Scope the token to the relevant **account** and, for custom domains, the relevant **zone**. A missing R2 permission fails with `Authentication error [code: 10000]` on `/r2/buckets/...`; a missing routes/DNS permission fails while provisioning the custom domain. The `User Details`/`Memberships` warnings Wrangler prints are only for `whoami` display and are not fatal.
|
||||||
|
|
||||||
|
## Configuration notes
|
||||||
|
|
||||||
|
- `wrangler-example.toml` is the template; `wrangler.toml` is generated locally.
|
||||||
|
- Keep `compatibility_date` fresh when doing runtime upgrades.
|
||||||
|
- `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. Attachments are also listed with download links on the admin email detail page and the public entry view.
|
||||||
|
|
||||||
|
Inline images (the ones an email references with `src="cid:…"`) are handled separately: they are still stored in R2 (and deleted with the email), but instead of appearing in the attachment list they render in place — the `cid:` reference is rewritten to the stored `/files/{id}/{filename}` URL in the feed, the admin preview, and the public entry view.
|
||||||
|
|
||||||
|
This feature is **optional**. If no R2 bucket is bound, attachments are silently ignored and nothing else changes.
|
||||||
|
|
||||||
|
**Setup (automated):** `setup.sh` now asks _"Enable email attachments stored in R2?"_. Answer yes and it creates the buckets (`<worker>-attachments` and `<worker>-attachments-preview`) and wires the binding into the generated `wrangler.toml` for you.
|
||||||
|
|
||||||
|
**Setup (manual):**
|
||||||
|
|
||||||
|
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" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
The binding is **per environment**: add it under every env you deploy (`[env.production]`, `[env.demo]`, …), each pointing at its own bucket.
|
||||||
|
3. Redeploy:
|
||||||
|
```bash
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Deploy token permission:** with an R2 binding, `wrangler deploy` verifies the bucket exists, so a scoped CI token also needs **Account → Workers R2 Storage** — see [Continuous deployment](#continuous-deployment-github-actions). Local `npx wrangler login` already has it.
|
||||||
|
|
||||||
|
**Turning it off:** set `ATTACHMENTS_ENABLED = "false"` in `[vars]` to disable attachments even while the R2 bucket stays bound (useful to cap usage on a demo). Any other value (or leaving it unset) keeps the feature on whenever R2 is configured.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Monitoring storage / free tier:** the status page (`/`) and `/api/v1/stats` report R2 space used (against the **10 GB** R2 free tier) and an estimate of KV space used (against the **1 GB** KV free tier). The figures are refreshed hourly by the cron trigger. KV usage is an estimate based on stored email sizes, so treat it as a lower bound.
|
||||||
|
|
||||||
|
### 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]`).
|
||||||
|
|
||||||
|
### REST API authentication
|
||||||
|
|
||||||
|
The versioned REST API (`/api/v1/*`) is authenticated independently of the cookie-based
|
||||||
|
admin UI — there is no CSRF check, so it is suited to server-to-server automation. A
|
||||||
|
request is authorized when **either**:
|
||||||
|
|
||||||
|
- it carries `Authorization: Bearer <ADMIN_PASSWORD>` (the same admin password secret), **or**
|
||||||
|
- it passes the reverse-proxy check above (`PROXY_TRUSTED_IPS` + `X-Auth-Proxy-Secret` + `Remote-User`).
|
||||||
|
|
||||||
|
The OpenAPI 3.1 spec (`/api/openapi.json`) and the Scalar reference (`/api/docs`) are
|
||||||
|
public. In the Scalar UI, click **Authorize** and paste the admin password as the bearer
|
||||||
|
token to try requests. See the route table in [README.md](README.md#rest-api).
|
||||||
|
|
||||||
|
## Upgrading dependencies
|
||||||
|
|
||||||
|
To refresh dependencies to latest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm outdated
|
||||||
|
npm install
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update `compatibility_date` and redeploy.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 Young Lee
|
Copyright (c) 2025 Young Lee
|
||||||
|
Copyright (c) 2025-2026 Julien Herr
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ kill-the-news keeps the same workflow while avoiding shared domains and shared d
|
|||||||
- Optional per-feed sender allowlist (`email@domain.com` or `domain.com`)
|
- Optional per-feed sender allowlist (`email@domain.com` or `domain.com`)
|
||||||
- RSS generation on demand (`/rss/:feedId`)
|
- RSS generation on demand (`/rss/:feedId`)
|
||||||
- Atom feed at `/atom/:feedId`
|
- Atom feed at `/atom/:feedId`
|
||||||
|
- Per-feed favicon derived from the last sender's domain (`/favicon/:feedId`), cached and shown in feeds + admin
|
||||||
|
- Automatic RFC 8058 one-click unsubscribe when a feed is deleted — stops newsletters from mailing the now-dead address
|
||||||
- Email attachments stored in Cloudflare R2 and exposed as RSS enclosures (optional)
|
- 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
|
||||||
|
- Versioned REST API (`/api/v1/*`) with an OpenAPI 3.1 spec and Scalar docs for automation
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ Common path:
|
|||||||
2. The Worker resolves the feed from the recipient address and stores the email in KV.
|
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.
|
3. `https://yourdomain.com/rss/:feedId` renders RSS from stored items.
|
||||||
4. `/admin` provides feed management and email deletion.
|
4. `/admin` provides feed management and email deletion.
|
||||||
|
5. `https://yourdomain.com/` shows a public status page with monitoring counters and a link to the admin.
|
||||||
|
|
||||||
Main routes:
|
Main routes:
|
||||||
|
|
||||||
@@ -49,157 +53,78 @@ Main routes:
|
|||||||
- `src/routes/rss.ts`: RSS rendering
|
- `src/routes/rss.ts`: RSS rendering
|
||||||
- `src/routes/atom.ts`: Atom feed rendering
|
- `src/routes/atom.ts`: Atom feed rendering
|
||||||
- `src/routes/files.ts`: attachment file serving from R2
|
- `src/routes/files.ts`: attachment file serving from R2
|
||||||
- `src/routes/admin.ts`: admin UI + feed CRUD
|
- `src/routes/admin.tsx`: admin UI + feed CRUD
|
||||||
|
- `src/routes/api/`: versioned REST API + OpenAPI spec/docs (`/api/v1/*`, `/api/openapi.json`, `/api/docs`)
|
||||||
|
- `src/lib/feed-service.ts`: shared feed create/update/delete (used by the admin UI and the REST API)
|
||||||
|
- `src/routes/home.tsx`: public status page (`GET /`)
|
||||||
|
|
||||||
## Requirements
|
### Monitoring
|
||||||
|
|
||||||
- Node.js 20+
|
`GET /api/v1/stats` returns JSON counters (public, no auth, CORS-enabled) for
|
||||||
- A Cloudflare account (free plan works — Workers, KV, and Email Routing are all included)
|
uptime/monitoring tools and the landing page:
|
||||||
- A domain added to Cloudflare as a zone (DNS managed by Cloudflare)
|
|
||||||
- A ForwardEmail account _(Option B only)_
|
|
||||||
|
|
||||||
## Cloudflare setup
|
| Field | Meaning |
|
||||||
|
| ----------------------------- | -------------------------------------------------------- |
|
||||||
|
| `active_feeds` | Feeds currently configured (live) |
|
||||||
|
| `feeds_created` | Total feeds ever created (cumulative) |
|
||||||
|
| `feeds_deleted` | Total feeds ever deleted (cumulative) |
|
||||||
|
| `emails_received` | Total emails ingested successfully (cumulative) |
|
||||||
|
| `emails_rejected` | Total emails rejected during validation (cumulative) |
|
||||||
|
| `websub_subscriptions_active` | Active WebSub subscriptions (live) |
|
||||||
|
| `last_email_at` | ISO 8601 date-time of the last ingested email |
|
||||||
|
| `last_feed_created_at` | ISO 8601 date-time of the last feed creation |
|
||||||
|
| `first_seen` | ISO 8601 date-time the instance first recorded a counter |
|
||||||
|
|
||||||
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).
|
The same figures are rendered on the public status page at `GET /`. Cumulative counters
|
||||||
|
are persisted in the `EMAIL_STORAGE` KV under the `stats:counters` key.
|
||||||
|
|
||||||
## Setup
|
### REST API
|
||||||
|
|
||||||
1. Clone this repository.
|
A versioned REST API lets you automate feed and email management without scraping the
|
||||||
2. Authenticate Wrangler:
|
admin UI. The OpenAPI 3.1 spec is served at `GET /api/openapi.json` and a rendered
|
||||||
```bash
|
reference (Scalar) at `GET /api/docs` — both public.
|
||||||
npx wrangler login
|
|
||||||
```
|
|
||||||
3. Run setup:
|
|
||||||
|
|
||||||
```bash
|
The feed and email endpoints require authentication, using either:
|
||||||
bash setup.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
The script will prompt for an admin password and your domain, then:
|
- **Bearer token**: `Authorization: Bearer <ADMIN_PASSWORD>`, or
|
||||||
- install npm dependencies
|
- **Reverse-proxy auth**: the same trusted-IP + `X-Auth-Proxy-Secret` + `Remote-User`
|
||||||
- verify Cloudflare auth (`wrangler whoami`)
|
headers as the admin UI (see [INSTALL.md](INSTALL.md)).
|
||||||
- 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
|
|
||||||
|
|
||||||
4. Configure email ingestion — choose **one** of the two options below.
|
`GET /api/v1/stats`, the OpenAPI spec, and the docs page are public.
|
||||||
|
|
||||||
### Option A — Cloudflare Email Workers (recommended)
|
| Method | Path | Auth | Purpose |
|
||||||
|
| -------- | ------------------------------------ | ------ | ------------------------ |
|
||||||
|
| `GET` | `/api/v1/feeds` | yes | List feeds |
|
||||||
|
| `POST` | `/api/v1/feeds` | yes | Create a feed |
|
||||||
|
| `GET` | `/api/v1/feeds/{feedId}` | yes | Get a feed |
|
||||||
|
| `PATCH` | `/api/v1/feeds/{feedId}` | yes | Update a feed |
|
||||||
|
| `DELETE` | `/api/v1/feeds/{feedId}` | yes | Delete a feed |
|
||||||
|
| `GET` | `/api/v1/feeds/{feedId}/emails` | yes | List a feed's emails |
|
||||||
|
| `GET` | `/api/v1/feeds/{feedId}/emails/{id}` | yes | Get a single email |
|
||||||
|
| `DELETE` | `/api/v1/feeds/{feedId}/emails/{id}` | yes | Delete a single email |
|
||||||
|
| `GET` | `/api/v1/stats` | public | Read monitoring counters |
|
||||||
|
|
||||||
No third-party service required. Cloudflare receives the email and hands it directly to the Worker.
|
The email `{id}` is the email's `receivedAt` timestamp (as returned by the list endpoint).
|
||||||
|
|
||||||
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 |
|
|
||||||
| ---- | ---- | ---------------------------------------------------- | ----------------------- |
|
|
||||||
| MX | @ | `mx1.forwardemail.net` | Priority `10`, DNS only |
|
|
||||||
| MX | @ | `mx2.forwardemail.net` | Priority `10`, DNS only |
|
|
||||||
| TXT | @ | `"forward-email=https://yourdomain.com/api/inbound"` | webhook target |
|
|
||||||
| 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:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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.
|
|
||||||
|
|
||||||
> **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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
# Create a feed
|
||||||
npm run dev
|
curl -X POST https://yourdomain.com/api/v1/feeds \
|
||||||
npm test
|
-H "Authorization: Bearer $ADMIN_PASSWORD" \
|
||||||
npm run build
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"title":"Daily Digest","allowedSenders":["news@example.com"]}'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration notes
|
## Installation
|
||||||
|
|
||||||
- `wrangler-example.toml` is the template; `wrangler.toml` is generated locally.
|
See **[INSTALL.md](INSTALL.md)** for the full setup, deployment, and configuration guide. Quick start:
|
||||||
- Keep `compatibility_date` fresh when doing runtime upgrades.
|
|
||||||
- `ADMIN_PASSWORD` is a Cloudflare Worker secret, not a plain env var in config.
|
|
||||||
|
|
||||||
### Feed size limit
|
```bash
|
||||||
|
npx wrangler login
|
||||||
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.
|
bash setup.sh # prompts for admin password + domain, provisions KV, generates wrangler.toml
|
||||||
|
npm run deploy # deploys the Worker and registers your custom domain
|
||||||
To override the threshold, add to `wrangler.toml` under `[vars]`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
FEED_MAX_SIZE_BYTES = "524288" # 512 KB — adjust as needed
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Email attachments (R2)
|
Then enable email ingestion (Cloudflare Email Workers or ForwardEmail) and open `https://yourdomain.com/admin`. Details, options, and configuration knobs (feed size limit, R2 attachments, reverse-proxy auth, CI deploys) are all in [INSTALL.md](INSTALL.md).
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -210,19 +135,6 @@ Password login remains available as a fallback when the proxy check fails.
|
|||||||
- 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.
|
- All secret comparisons (admin password, proxy secret) use constant-time comparison to prevent timing attacks.
|
||||||
|
|
||||||
## Upgrading dependencies
|
|
||||||
|
|
||||||
To refresh dependencies to latest:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm outdated
|
|
||||||
npm install
|
|
||||||
npm test
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
Then update `compatibility_date` and redeploy.
|
|
||||||
|
|
||||||
## Acknowledgements
|
## 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).
|
- [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).
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
kill-the-news is a self-hosted, single-Worker application. Only the latest
|
||||||
|
release on the `main` branch receives security fixes. If you run a fork or an
|
||||||
|
older deployment, update to the latest `main` before reporting an issue.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
**Please do not open a public GitHub issue for security problems.**
|
||||||
|
|
||||||
|
Report privately through one of:
|
||||||
|
|
||||||
|
- [GitHub Security Advisories](https://github.com/juherr/kill-the-news/security/advisories/new) (preferred)
|
||||||
|
- Email: me@juherr.dev
|
||||||
|
|
||||||
|
Please include:
|
||||||
|
|
||||||
|
- A description of the issue and its impact
|
||||||
|
- Steps to reproduce (proof-of-concept if possible)
|
||||||
|
- Affected route, file, or configuration
|
||||||
|
- Any suggested remediation
|
||||||
|
|
||||||
|
You can expect an acknowledgement within a few days. Since this is a
|
||||||
|
volunteer-maintained project, fix timelines depend on severity and
|
||||||
|
availability, but credible reports are taken seriously. Coordinated disclosure
|
||||||
|
is appreciated — please give a reasonable window for a fix before going public.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Because this Worker ingests email and exposes feeds, the security-sensitive
|
||||||
|
surface includes:
|
||||||
|
|
||||||
|
- **Admin authentication** — password handling, the signed session cookie, and
|
||||||
|
constant-time secret comparison (`ADMIN_PASSWORD`, `PROXY_AUTH_SECRET`).
|
||||||
|
- **Inbound ingestion** — the ForwardEmail webhook (`POST /api/inbound`),
|
||||||
|
IP allowlisting, and the Cloudflare Email Workers handler.
|
||||||
|
- **Email rendering** — HTML sanitization for stored emails and feed output
|
||||||
|
(XSS via `entries/`, `rss/`, `atom/`, inline `cid:` images).
|
||||||
|
- **Public endpoints** — `GET /`, `GET /api/stats`, `GET /rss/:feedId`,
|
||||||
|
`GET /atom/:feedId`, `GET /files/:attachmentId/:filename`,
|
||||||
|
`GET /favicon/:feedId`.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
- Vulnerabilities in Cloudflare, ForwardEmail, or other third-party
|
||||||
|
infrastructure (report those to the respective vendor).
|
||||||
|
- Misconfiguration of a self-hosted deployment (e.g. a weak `ADMIN_PASSWORD`,
|
||||||
|
an exposed `workers.dev` subdomain, or committing secrets). See the security
|
||||||
|
notes in [README.md](README.md) and [INSTALL.md](INSTALL.md).
|
||||||
|
- Denial of service from sending large volumes of email.
|
||||||
|
|
||||||
|
## Hardening reminders for operators
|
||||||
|
|
||||||
|
- Use a strong, unique `ADMIN_PASSWORD` and rotate it periodically.
|
||||||
|
- Set `ADMIN_PASSWORD` via `wrangler secret put` — never in config files.
|
||||||
|
- Disable the `workers.dev` subdomain in production (`workers_dev = false`),
|
||||||
|
since `CF-Connecting-IP` can be spoofed on direct `workers.dev` requests.
|
||||||
|
- Set per-feed `Allowed senders` for high-value feeds.
|
||||||
|
- Never commit `wrangler.toml` or `.dev.vars` (both are gitignored).
|
||||||
@@ -10,6 +10,8 @@ Feature gaps identified by comparing with [kill-the-newsletter](https://github.c
|
|||||||
|
|
||||||
- [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).
|
- [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).
|
||||||
|
|
||||||
|
- [x] **Project favicon** — serve a single bundled icon at `/favicon.ico` and add a `<link rel="icon">` in the shared `Layout` so the admin UI, status page, and entry views stop 404-ing. Doubles as the default/fallback icon for the per-feed favicon feature below.
|
||||||
|
|
||||||
## Medium effort
|
## 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] **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.
|
||||||
@@ -18,6 +20,10 @@ Feature gaps identified by comparing with [kill-the-newsletter](https://github.c
|
|||||||
|
|
||||||
- [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.
|
- [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.
|
||||||
|
|
||||||
|
- [x] **Per-feed favicon from the last sender's domain** — give each feed an icon by fetching the favicon of the last sender's domain, so feeds are visually distinguishable in readers and the admin UI. Resolve the domain from the most recent email's `from`, fetch its favicon (e.g. `https://<domain>/favicon.ico` or a parsed `<link rel="icon">`, with a fallback service), and cache the result aggressively (KV/R2 + Cache API with a long TTL) so it isn't re-fetched on every request. Expose it via the RSS `<image>` / Atom `<icon>` and the admin feed list.
|
||||||
|
|
||||||
|
- [x] **RFC 8058 one-click unsubscribe on feed deletion** — when a feed is deleted, automatically unsubscribe from the newsletters that fed it so messages stop arriving at the now-dead address. Parse and store the `List-Unsubscribe` / `List-Unsubscribe-Post` headers ([RFC 8058](https://www.rfc-editor.org/rfc/rfc8058.txt)) from incoming emails, then on deletion POST `List-Unsubscribe=One-Click` to each stored unsubscribe URL. Requires capturing the headers during ingestion (`src/lib/email-processor.ts`) and firing the outbound requests from the feed-delete paths (`src/routes/admin/feeds.tsx`), ideally via `ctx.waitUntil`.
|
||||||
|
|
||||||
## Heavy
|
## 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] **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}`.
|
||||||
@@ -26,4 +32,22 @@ Feature gaps identified by comparing with [kill-the-newsletter](https://github.c
|
|||||||
|
|
||||||
- [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.
|
- [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.
|
||||||
|
|
||||||
|
- [x] **REST API with OpenAPI description** — expose a documented, machine-consumable REST API for feed/email management (create/list/update/delete feeds, list/read/delete emails, read stats) so the service can be automated without scraping the admin UI. Implemented as a versioned `/api/v1/*` surface (Bearer-token auth with the admin password, plus the existing proxy-auth) built on `@hono/zod-openapi`; the OpenAPI 3.1 spec is served at `/api/openapi.json` with a Scalar docs page at `/api/docs`. Feed create/update/delete logic was extracted into `src/lib/feed-service.ts` so the admin UI and the REST API share a single source of truth.
|
||||||
|
|
||||||
- [ ] **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.
|
- [ ] **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.
|
||||||
|
|
||||||
|
## Per-feed favicon — design notes
|
||||||
|
|
||||||
|
Breakdown of the _"Per-feed favicon from the last sender's domain"_ item above. Goal: each feed shows an icon derived from its newsletter source, fetched once and cached so it never re-fetches on a normal request.
|
||||||
|
|
||||||
|
- [x] **Resolve the sender domain** — on ingestion, extract the domain from the latest email's `from` address (`extractEmailDomain` in `src/utils/favicon-fetcher.ts`) and persist it as `iconDomain` on the feed metadata so the icon tracks the most recent sender.
|
||||||
|
|
||||||
|
- [x] **Fetch the favicon** — resolve an icon URL for the domain: try `https://<domain>/favicon.ico`, then fall back to `https://icons.duckduckgo.com/ip3/<domain>.ico`. Runs async via `ctx.waitUntil` so it never blocks email processing.
|
||||||
|
|
||||||
|
- [x] **Cache aggressively** — store the fetched bytes (base64) keyed by domain in KV with a 1-week TTL (`ICON_TTL_SECONDS`). The domain is the cache key so feeds from the same sender share one fetch; the fetch only fires when the cache entry is absent/expired.
|
||||||
|
|
||||||
|
- [x] **Serve endpoint** — `GET /favicon/:feedId` returns the cached bytes with the correct `Content-Type` and a long `Cache-Control`, falling back to the project favicon when no domain icon is found.
|
||||||
|
|
||||||
|
- [x] **Expose in outputs** — the icon is referenced from the RSS `<image>` and Atom `<icon>`/`<logo>` in `src/utils/feed-generator.ts`, and rendered next to each feed in the admin list/table (`src/routes/admin.tsx`).
|
||||||
|
|
||||||
|
- [x] **Failure handling** — missing/blocked favicons degrade gracefully to the project favicon fallback (negative cache entry); icon fetch errors never surface to ingestion or feed rendering.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#f6821f"/>
|
||||||
|
<g fill="none" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M7 9h18c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H7c-1.1 0-2-.9-2-2V11c0-1.1.9-2 2-2z"/>
|
||||||
|
<polyline points="27,11 16,18.5 5,11"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
+252
-20
@@ -5,6 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>kill-the-news — Private newsletter feeds on Cloudflare Workers</title>
|
<title>kill-the-news — Private newsletter feeds on Cloudflare Workers</title>
|
||||||
<meta name="description" content="Convert email newsletters into private RSS feeds using Cloudflare Workers. Self-hosted, free tier, your own domain." />
|
<meta name="description" content="Convert email newsletters into private RSS feeds using Cloudflare Workers. Self-hosted, free tier, your own domain." />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="32x32" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
@@ -171,6 +173,38 @@
|
|||||||
|
|
||||||
.section-header { margin-bottom: 3rem; }
|
.section-header { margin-bottom: 3rem; }
|
||||||
|
|
||||||
|
/* ── FAQ ── */
|
||||||
|
.faq-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.faq-item {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.faq-item[open] { border-color: rgba(246,130,31,0.4); }
|
||||||
|
.faq-item summary {
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
padding: 1.1rem 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.faq-item summary::-webkit-details-marker { display: none; }
|
||||||
|
.faq-item summary::after { content: "+"; color: var(--accent); font-size: 1.2rem; line-height: 1; }
|
||||||
|
.faq-item[open] summary::after { content: "−"; }
|
||||||
|
.faq-item p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 1.35rem 1.2rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.faq-item a { color: var(--accent); }
|
||||||
|
|
||||||
/* ── Features ── */
|
/* ── Features ── */
|
||||||
.features-grid {
|
.features-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -446,6 +480,35 @@
|
|||||||
.demo-note { text-align: left; }
|
.demo-note { text-align: left; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Live stats ── */
|
||||||
|
.stats-section { padding: 4rem 2rem; }
|
||||||
|
.stats-inner { max-width: 1100px; margin: 0 auto; text-align: center; }
|
||||||
|
.stats-live {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||||
|
font-size: 0.78rem; color: var(--muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.stats-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
|
||||||
|
animation: stats-pulse 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes stats-pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(246,130,31,0.5); }
|
||||||
|
70% { box-shadow: 0 0 0 8px rgba(246,130,31,0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(246,130,31,0); }
|
||||||
|
}
|
||||||
|
.stats-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 2rem; max-width: 600px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
.stat-num {
|
||||||
|
font-size: clamp(2.5rem, 6vw, 4rem); font-weight: 700;
|
||||||
|
color: var(--accent); letter-spacing: -0.03em;
|
||||||
|
font-variant-numeric: tabular-nums; line-height: 1;
|
||||||
|
}
|
||||||
|
.stat-label { font-size: 0.9rem; color: var(--muted); margin-top: 0.6rem; }
|
||||||
|
@media (max-width: 600px) { .stats-grid { gap: 1.5rem; } }
|
||||||
|
|
||||||
/* ── Nav links ── */
|
/* ── Nav links ── */
|
||||||
nav-links {
|
nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -474,7 +537,7 @@
|
|||||||
|
|
||||||
.install-step {
|
.install-step {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 48px 1fr;
|
grid-template-columns: 48px minmax(0, 1fr);
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
@@ -561,30 +624,49 @@
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
|
table-layout: fixed;
|
||||||
}
|
}
|
||||||
.waf-table th {
|
.waf-table th,
|
||||||
|
.waf-table td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.6rem;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.waf-table thead th:first-child { width: 38%; }
|
||||||
|
.waf-table thead th {
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.waf-table tbody th {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
|
||||||
.waf-table td {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
vertical-align: top;
|
|
||||||
}
|
}
|
||||||
.waf-table td code {
|
.waf-table tbody td {
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.waf-table code {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.waf-table tr:last-child td { border-bottom: none; }
|
.waf-table tbody tr:last-child th,
|
||||||
|
.waf-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.step:not(:last-child)::after { display: none; }
|
.step:not(:last-child)::after { display: none; }
|
||||||
.step { padding-right: 0; }
|
.step { padding-right: 0; }
|
||||||
|
section { padding-left: 1.25rem; padding-right: 1.25rem; }
|
||||||
|
#how-it-works { padding-left: 1.25rem; padding-right: 1.25rem; }
|
||||||
|
.install-step { grid-template-columns: 34px minmax(0, 1fr); gap: 0.85rem; }
|
||||||
|
.install-step-num { width: 34px; height: 34px; font-size: 0.8rem; }
|
||||||
|
.install-step-connector { margin-left: 16px; }
|
||||||
|
.code-block pre { padding: 1rem; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -603,6 +685,7 @@
|
|||||||
<a href="#features" class="nav-link">Features</a>
|
<a href="#features" class="nav-link">Features</a>
|
||||||
<a href="#how-it-works" class="nav-link">How it works</a>
|
<a href="#how-it-works" class="nav-link">How it works</a>
|
||||||
<a href="#install" class="nav-link">Install</a>
|
<a href="#install" class="nav-link">Install</a>
|
||||||
|
<a href="#faq" class="nav-link">FAQ</a>
|
||||||
<a href="https://github.com/sponsors/juherr" class="nav-link nav-link-sponsor" target="_blank" rel="noopener">
|
<a href="https://github.com/sponsors/juherr" class="nav-link nav-link-sponsor" target="_blank" rel="noopener">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21.593c-.524-.505-3.655-3.536-5.905-5.8C3.39 13.078 2 10.538 2 8a6 6 0 0 1 10-4.472A6 6 0 0 1 22 8c0 2.538-1.39 5.078-4.095 7.793-2.25 2.264-5.381 5.295-5.905 5.8z"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21.593c-.524-.505-3.655-3.536-5.905-5.8C3.39 13.078 2 10.538 2 8a6 6 0 0 1 10-4.472A6 6 0 0 1 22 8c0 2.538-1.39 5.078-4.095 7.793-2.25 2.264-5.381 5.295-5.905 5.8z"/></svg>
|
||||||
Sponsor
|
Sponsor
|
||||||
@@ -624,7 +707,7 @@
|
|||||||
<h1>Turn email newsletters into <span>private RSS feeds</span></h1>
|
<h1>Turn email newsletters into <span>private RSS feeds</span></h1>
|
||||||
<p>Self-hosted on Cloudflare Workers. Your data stays in your own account, served from your own domain.</p>
|
<p>Self-hosted on Cloudflare Workers. Your data stays in your own account, served from your own domain.</p>
|
||||||
<div class="hero-ctas">
|
<div class="hero-ctas">
|
||||||
<a href="https://demo.kill-the.news/admin" class="btn btn-primary" target="_blank" rel="noopener">
|
<a href="https://demo.kill-the.news" class="btn btn-primary" target="_blank" rel="noopener">
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
Try the demo
|
Try the demo
|
||||||
</a>
|
</a>
|
||||||
@@ -636,6 +719,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Live stats (demo instance) -->
|
||||||
|
<section id="stats" class="stats-section" hidden>
|
||||||
|
<div class="stats-inner">
|
||||||
|
<div class="stats-live"><span class="stats-dot"></span> Live from the demo instance</div>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-num" data-stat="feeds_created">0</div>
|
||||||
|
<div class="stat-label">Feeds created</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-num" data-stat="emails_received">0</div>
|
||||||
|
<div class="stat-label">Emails received</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Demo banner -->
|
<!-- Demo banner -->
|
||||||
<div class="demo-banner">
|
<div class="demo-banner">
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
@@ -646,13 +746,13 @@
|
|||||||
Create a feed, grab the RSS URL, and add it to your reader — all without deploying anything.
|
Create a feed, grab the RSS URL, and add it to your reader — all without deploying anything.
|
||||||
</p>
|
</p>
|
||||||
<div class="demo-creds">
|
<div class="demo-creds">
|
||||||
<span class="cred-item">URL <strong>demo.kill-the.news/admin</strong></span>
|
<span class="cred-item">URL <strong>demo.kill-the.news</strong></span>
|
||||||
<span class="cred-sep">·</span>
|
<span class="cred-sep">·</span>
|
||||||
<span class="cred-item">Password <strong>password</strong></span>
|
<span class="cred-item">Password <strong>password</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="demo-actions">
|
<div class="demo-actions">
|
||||||
<a href="https://demo.kill-the.news/admin" class="btn btn-primary" target="_blank" rel="noopener" style="font-size:0.95rem;padding:0.6rem 1.35rem;">
|
<a href="https://demo.kill-the.news" class="btn btn-primary" target="_blank" rel="noopener" style="font-size:0.95rem;padding:0.6rem 1.35rem;">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||||
Open demo
|
Open demo
|
||||||
</a>
|
</a>
|
||||||
@@ -710,6 +810,30 @@
|
|||||||
<p>Email attachments are stored in Cloudflare R2 and exposed as RSS enclosures — no extra hosting needed.</p>
|
<p>Email attachments are stored in Cloudflare R2 and exposed as RSS enclosures — no extra hosting needed.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Per-Feed Icons</h3>
|
||||||
|
<p>Each feed picks up the favicon of its newsletter's sender domain, so feeds are easy to tell apart in your reader and the admin UI.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Auto-Expiring Feeds</h3>
|
||||||
|
<p>Give a feed a lifetime and it deletes itself when the timer runs out — perfect as a disposable inbox for one-off sign-ups you don't want to keep around.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Auto-Unsubscribe on Delete</h3>
|
||||||
|
<p>Deleting a feed fires RFC 8058 one-click unsubscribe requests to its newsletters, so the messages stop arriving at the now-dead address.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<div class="feature-icon">
|
<div class="feature-icon">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
@@ -718,6 +842,14 @@
|
|||||||
<p>Optionally delegate admin authentication to Authelia, Authentik, or any reverse proxy that sets <code style="font-family:monospace;font-size:0.8em;color:var(--accent)">Remote-User</code>.</p>
|
<p>Optionally delegate admin authentication to Authelia, Authentik, or any reverse proxy that sets <code style="font-family:monospace;font-size:0.8em;color:var(--accent)">Remote-User</code>.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="feature-card">
|
||||||
|
<div class="feature-icon">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>REST API & OpenAPI</h3>
|
||||||
|
<p>Automate feeds and emails through a versioned REST API, documented with an OpenAPI 3.1 spec and a <a href="https://demo.kill-the.news/api/docs" target="_blank" rel="noopener" style="color:var(--accent)">live interactive reference</a>.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -938,23 +1070,36 @@ bucket_name = "kill-the-news-attachments"</span></pre>
|
|||||||
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> WAF rules</div>
|
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> WAF rules</div>
|
||||||
<table class="waf-table">
|
<table class="waf-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>Endpoint</th><th>Condition (URI Path)</th><th>Limit (recommended)</th><th>Limit (free tier)</th><th>Action (recommended)</th><th>Action (free tier)</th></tr>
|
<tr>
|
||||||
|
<th scope="col">Setting</th>
|
||||||
|
<th scope="col"><code>/api/inbound</code></th>
|
||||||
|
<th scope="col"><code>/admin*</code></th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>/api/inbound</code></td>
|
<th scope="row">Condition (URI Path)</th>
|
||||||
<td>wildcard <code>/api/inbound/*</code></td>
|
<td>wildcard <code>/api/inbound/*</code></td>
|
||||||
<td>60 req / min / IP</td>
|
<td>wildcard <code>/admin/*</code></td>
|
||||||
<td>10 req / 10 s / IP</td>
|
|
||||||
<td>Block (1 min)</td>
|
|
||||||
<td>Block (10 s)</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>/admin*</code></td>
|
<th scope="row">Limit (recommended)</th>
|
||||||
<td>wildcard <code>/admin/*</code></td>
|
<td>60 req / min / IP</td>
|
||||||
<td>20 req / min / IP</td>
|
<td>20 req / min / IP</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Limit (free tier)</th>
|
||||||
|
<td>10 req / 10 s / IP</td>
|
||||||
<td>20 req / 10 s / IP</td>
|
<td>20 req / 10 s / IP</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Action (recommended)</th>
|
||||||
|
<td>Block (1 min)</td>
|
||||||
<td>Managed Challenge (5 min)</td>
|
<td>Managed Challenge (5 min)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Action (free tier)</th>
|
||||||
|
<td>Block (10 s)</td>
|
||||||
<td>Managed Challenge (10 s)</td>
|
<td>Managed Challenge (10 s)</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -1007,6 +1152,57 @@ bucket_name = "kill-the-news-attachments"</span></pre>
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- FAQ -->
|
||||||
|
<section id="faq">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-label">FAQ</div>
|
||||||
|
<h2 class="section-title">Questions & answers</h2>
|
||||||
|
<p class="section-sub">The practical stuff — subscribing, privacy, troubleshooting, and how kill-the-news differs.</p>
|
||||||
|
</div>
|
||||||
|
<div class="faq-list">
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>How does kill-the-news work?</summary>
|
||||||
|
<p>Create a feed in the admin UI and you get a unique address on your domain (e.g. <code style="font-family:monospace;color:var(--accent)">newsletter.42@yourdomain.com</code>) plus an RSS and an Atom feed. Any email sent to that address is turned into entries in those feeds.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>How do I confirm a newsletter subscription?</summary>
|
||||||
|
<p>Confirmation emails arrive as feed entries — open the entry in your reader and click the confirmation link. If a publisher requires a reply, subscribe with your normal inbox instead and set up a filter that auto-forwards its mail to your feed address.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Are my feeds private?</summary>
|
||||||
|
<p>Yes. Each feed URL carries an unguessable ID, it is served from your own domain on your own Cloudflare account, and the admin UI is password-protected. Treat the feed URL like a password — anyone who has it can read your newsletters.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Why are old entries disappearing?</summary>
|
||||||
|
<p>Feeds honor an optional size and time-to-live cap so RSS readers stay happy — some readers choke on feeds that grow too large. When a limit is reached, the oldest entries (and their R2 attachments) are purged automatically.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Can I share a feed with someone?</summary>
|
||||||
|
<p>Don't. Anyone with the URL can read your newsletters and even unsubscribe you. Share the project instead, so others can self-host and create their own feeds.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Why isn't my feed updating?</summary>
|
||||||
|
<p>Send a test email to the feed address. If it shows up within a minute, the delay is on the newsletter publisher's side, not kill-the-news. Readers that support WebSub get near-instant push updates instead of waiting for the next poll.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>How is this different from kill-the-newsletter.com?</summary>
|
||||||
|
<p>kill-the-news is self-hosted on your own Cloudflare account: your data, your domain, RSS <em>and</em> Atom output, attachments served as enclosures, WebSub push updates — all running on the free tier.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>How much does it cost?</summary>
|
||||||
|
<p>It runs on Cloudflare's free tier (Workers + KV + R2) plus the cost of your domain. With Cloudflare Email Routing, no third-party service is required at all.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>How do I delete a feed?</summary>
|
||||||
|
<p>From the password-protected admin UI — open the Feeds tab and delete it there. Its entries and attachments are removed along with it.</p>
|
||||||
|
</details>
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Does it handle attachments?</summary>
|
||||||
|
<p>Yes — optionally. When an R2 bucket is configured, email attachments are stored there and exposed as RSS/Atom enclosures, downloadable from each entry. It's off by default: if no R2 bucket is bound (or you set <code>ATTACHMENTS_ENABLED = "false"</code>), attachments are simply skipped and everything else works as usual. R2 usage is shown on the status page so you can stay within the 10 GB free tier.</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Tech Stack -->
|
<!-- Tech Stack -->
|
||||||
<section id="tech-stack" style="padding-top:0;">
|
<section id="tech-stack" style="padding-top:0;">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
@@ -1064,5 +1260,41 @@ bucket_name = "kill-the-news-attachments"</span></pre>
|
|||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(async function () {
|
||||||
|
const section = document.getElementById('stats');
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://demo.kill-the.news/api/v1/stats', { cache: 'no-store' });
|
||||||
|
if (!res.ok) return; // section stays hidden
|
||||||
|
data = await res.json();
|
||||||
|
} catch { return; }
|
||||||
|
|
||||||
|
const nums = section.querySelectorAll('.stat-num');
|
||||||
|
nums.forEach(el => { el.dataset.value = data[el.dataset.stat] ?? 0; });
|
||||||
|
section.hidden = false;
|
||||||
|
|
||||||
|
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const fmt = n => n.toLocaleString('en-US');
|
||||||
|
const ease = t => 1 - Math.pow(1 - t, 3);
|
||||||
|
|
||||||
|
function animate(el) {
|
||||||
|
const target = Number(el.dataset.value) || 0;
|
||||||
|
if (reduce || target === 0) { el.textContent = fmt(target); return; }
|
||||||
|
const dur = 1400, start = performance.now();
|
||||||
|
(function step(now) {
|
||||||
|
const t = Math.min((now - start) / dur, 1);
|
||||||
|
el.textContent = fmt(Math.round(ease(t) * target));
|
||||||
|
if (t < 1) requestAnimationFrame(step);
|
||||||
|
})(performance.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(e => { if (e.isIntersecting) { animate(e.target); io.unobserve(e.target); } });
|
||||||
|
}, { threshold: 0.4 });
|
||||||
|
nums.forEach(el => io.observe(el));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ import prettier from "eslint-config-prettier";
|
|||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist/", "coverage/"] },
|
{ ignores: ["dist/", "coverage/", "src/scripts/generated/"] },
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
prettier,
|
prettier,
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+134
-4
@@ -9,7 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hono/zod-openapi": "^1.4.0",
|
||||||
"@hono/zod-validator": "^0.8.0",
|
"@hono/zod-validator": "^0.8.0",
|
||||||
|
"@scalar/hono-api-reference": "^0.10.19",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"feed": "5.2.1",
|
"feed": "5.2.1",
|
||||||
"hono": "4.12.22",
|
"hono": "4.12.22",
|
||||||
@@ -37,6 +39,18 @@
|
|||||||
"wrangler": "4.94.0"
|
"wrangler": "4.94.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@asteasolutions/zod-to-openapi": {
|
||||||
|
"version": "8.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz",
|
||||||
|
"integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"openapi3-ts": "^4.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||||
@@ -822,6 +836,24 @@
|
|||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hono/zod-openapi": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-AFchqR1N/NxfI4hUOSGI2/g8zLROxA1OE7Oh5JJFlTaGxhrdRyH+93gd0tIBpb0z8s9r8hUoNnaOBfHbdb4NMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asteasolutions/zod-to-openapi": "^8.5.0",
|
||||||
|
"@hono/zod-validator": "^0.8.0",
|
||||||
|
"openapi3-ts": "^4.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"hono": ">=4.10.0",
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hono/zod-validator": {
|
"node_modules/@hono/zod-validator": {
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.8.0.tgz",
|
||||||
@@ -1954,6 +1986,99 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@scalar/client-side-rendering": {
|
||||||
|
"version": "0.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.1.12.tgz",
|
||||||
|
"integrity": "sha512-prwHK4ozTU268BHZ/5OstoKB23JSidDuvddAOp0bVz9c29ZxsyzzxPtPcVgF7X16LiZnS1OzY030FoDCM+iC9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@scalar/schemas": "0.3.2",
|
||||||
|
"@scalar/types": "0.12.2",
|
||||||
|
"@scalar/validation": "0.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scalar/helpers": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-gmOC6VravNB9VDl6wnt/GOj4K/hn48tj5bpW4AM4MhH8Ubil6uu7g1DSoKHwltu8Ks79KEtR6JmOrROi9R7jaQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scalar/hono-api-reference": {
|
||||||
|
"version": "0.10.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scalar/hono-api-reference/-/hono-api-reference-0.10.19.tgz",
|
||||||
|
"integrity": "sha512-6EfwN/lfPvePzAxe9UE8fr/ZuAAqS6ttUwQu9JTgk2Xl/clicaVVSOc0gyGt+8GLXdysoNinjZ74we8xqNWyCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@scalar/client-side-rendering": "0.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"hono": "^4.12.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scalar/schemas": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-iadXBgJ02XUU5C5s6/xh/PmGLzUPd7X8upXIvPWBXDcQ4FHACNgkG8PPZ/beYM8UPDDkTUPM3ygEs0G6jKwGjQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@scalar/helpers": "0.8.0",
|
||||||
|
"@scalar/validation": "0.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scalar/types": {
|
||||||
|
"version": "0.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.12.2.tgz",
|
||||||
|
"integrity": "sha512-EzLkubCb7xioiTm9eYnmn/032akaq4kkrrdclgV2uezwtniR8ErQICjhMl2AjBWL6nstHiFZ9RnPZm2Z2/KM0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@scalar/helpers": "0.8.0",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
|
"type-fest": "^5.3.1",
|
||||||
|
"zod": "^4.3.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scalar/types/node_modules/nanoid": {
|
||||||
|
"version": "5.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
|
||||||
|
"integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || >=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@scalar/validation": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-tpmmG+/xRE2Kn9RpflU3AIyZv08v10+E1ZrJCx7z6+/91zHVxy0M73kC1LT4/8PbYNt85ywyC8+n+D99JdMcGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sindresorhus/is": {
|
"node_modules/@sindresorhus/is": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
|
||||||
@@ -4459,6 +4584,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openapi3-ts": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -5026,7 +5160,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -5150,7 +5283,6 @@
|
|||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz",
|
||||||
"integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==",
|
"integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==",
|
||||||
"dev": true,
|
|
||||||
"license": "(MIT OR CC0-1.0)",
|
"license": "(MIT OR CC0-1.0)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tagged-tag": "^1.0.0"
|
"tagged-tag": "^1.0.0"
|
||||||
@@ -6097,9 +6229,7 @@
|
|||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
+5
-2
@@ -16,11 +16,12 @@
|
|||||||
"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",
|
"typecheck": "tsc --noEmit && npm run typecheck:client",
|
||||||
|
"typecheck:client": "tsc -p src/scripts/client/tsconfig.json --noEmit",
|
||||||
"prepare": "husky && npm run build:client"
|
"prepare": "husky && npm run build:client"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,js}": [
|
"*.{ts,tsx,js,jsx}": [
|
||||||
"eslint --fix",
|
"eslint --fix",
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
],
|
],
|
||||||
@@ -50,7 +51,9 @@
|
|||||||
"wrangler": "4.94.0"
|
"wrangler": "4.94.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hono/zod-openapi": "^1.4.0",
|
||||||
"@hono/zod-validator": "^0.8.0",
|
"@hono/zod-validator": "^0.8.0",
|
||||||
|
"@scalar/hono-api-reference": "^0.10.19",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"feed": "5.2.1",
|
"feed": "5.2.1",
|
||||||
"hono": "4.12.22",
|
"hono": "4.12.22",
|
||||||
|
|||||||
@@ -134,6 +134,34 @@ if [ -z "$domain" ]; then
|
|||||||
fi
|
fi
|
||||||
echo "✅ Domain: $domain"
|
echo "✅ Domain: $domain"
|
||||||
|
|
||||||
|
ENABLE_R2=false
|
||||||
|
R2_BUCKET=""
|
||||||
|
R2_PREVIEW_BUCKET=""
|
||||||
|
read -r -p "Enable email attachments stored in R2? [y/N]: " enable_r2
|
||||||
|
if [[ "$enable_r2" =~ ^[Yy]$ ]]; then
|
||||||
|
R2_BUCKET="${WORKER_NAME}-attachments"
|
||||||
|
R2_PREVIEW_BUCKET="${R2_BUCKET}-preview"
|
||||||
|
echo "🪣 Creating R2 buckets..."
|
||||||
|
set +e
|
||||||
|
R2_OUT="$(npx wrangler r2 bucket create "$R2_BUCKET" 2>&1)"
|
||||||
|
R2_STATUS=$?
|
||||||
|
R2_PREVIEW_OUT="$(npx wrangler r2 bucket create "$R2_PREVIEW_BUCKET" 2>&1)"
|
||||||
|
R2_PREVIEW_STATUS=$?
|
||||||
|
set -e
|
||||||
|
# An existing bucket is fine; only treat real failures as blocking.
|
||||||
|
echo "$R2_OUT" | grep -qi "already exists" && R2_STATUS=0
|
||||||
|
echo "$R2_PREVIEW_OUT" | grep -qi "already exists" && R2_PREVIEW_STATUS=0
|
||||||
|
if [ "$R2_STATUS" -eq 0 ] && [ "$R2_PREVIEW_STATUS" -eq 0 ]; then
|
||||||
|
ENABLE_R2=true
|
||||||
|
echo " ✅ R2 bucket: $R2_BUCKET"
|
||||||
|
echo " ✅ R2 preview bucket: $R2_PREVIEW_BUCKET"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Could not create R2 buckets (is R2 enabled on your account?)."
|
||||||
|
echo " Attachments will stay disabled — see INSTALL.md → 'Email attachments (R2)'."
|
||||||
|
echo "$R2_OUT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
escape_sed_replacement() {
|
escape_sed_replacement() {
|
||||||
printf '%s' "$1" | sed -e 's/[\/&]/\\&/g'
|
printf '%s' "$1" | sed -e 's/[\/&]/\\&/g'
|
||||||
}
|
}
|
||||||
@@ -158,8 +186,26 @@ else
|
|||||||
sed -i "s/REPLACE_WITH_COMPATIBILITY_DATE/$COMPATIBILITY_DATE_ESCAPED/g" wrangler.toml
|
sed -i "s/REPLACE_WITH_COMPATIBILITY_DATE/$COMPATIBILITY_DATE_ESCAPED/g" wrangler.toml
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ "$ENABLE_R2" = true ]; then
|
||||||
|
echo "🔗 Enabling R2 attachment binding in wrangler.toml..."
|
||||||
|
node - "wrangler.toml" "$R2_BUCKET" "$R2_PREVIEW_BUCKET" <<'NODE'
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const [file, bucket, previewBucket] = process.argv.slice(2);
|
||||||
|
let txt = fs.readFileSync(file, "utf8");
|
||||||
|
txt = txt.split("REPLACE_WITH_YOUR_PREVIEW_BUCKET_NAME").join(previewBucket);
|
||||||
|
txt = txt.split("REPLACE_WITH_YOUR_BUCKET_NAME").join(bucket);
|
||||||
|
// Uncomment the commented r2_buckets blocks (global + [env.production]).
|
||||||
|
txt = txt.replace(
|
||||||
|
/# r2_buckets = \[\n#(\s+\{ binding = "ATTACHMENT_BUCKET".*\})\n# \]/g,
|
||||||
|
'r2_buckets = [\n$1\n]',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(file, txt);
|
||||||
|
NODE
|
||||||
|
echo " ✅ ATTACHMENT_BUCKET bound to $R2_BUCKET"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✅ wrangler.toml has been created and configured successfully!"
|
echo "✅ wrangler.toml has been created and configured successfully!"
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Setup complete! Next steps:"
|
echo "✅ Setup complete! Next steps:"
|
||||||
echo "1. Set up MX records for your domain with ForwardEmail.net (see README for details)"
|
echo "1. Configure email ingestion — Cloudflare Email Workers or ForwardEmail (see INSTALL.md for details)"
|
||||||
echo "2. Deploy with 'npm run deploy'"
|
echo "2. Deploy with 'npm run deploy'"
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import "../test/setup";
|
import { http, HttpResponse } from "msw";
|
||||||
import { createMockEnv, MockR2 } from "../test/setup";
|
import { createMockEnv, MockR2, server } from "../test/setup";
|
||||||
import {
|
import {
|
||||||
processEmail,
|
processEmail,
|
||||||
ProcessEmailInput,
|
ProcessEmailInput,
|
||||||
RawAttachment,
|
RawAttachment,
|
||||||
} from "./email-processor";
|
} from "./email-processor";
|
||||||
|
import { getCounters } from "../application/stats";
|
||||||
|
|
||||||
|
const iconKey = (domain: string) => `icon:${domain}`;
|
||||||
|
|
||||||
const VALID_FEED_ID = "apple.mountain.42";
|
const VALID_FEED_ID = "apple.mountain.42";
|
||||||
const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`;
|
const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`;
|
||||||
@@ -36,12 +39,12 @@ describe("processEmail", () => {
|
|||||||
makeInput({ toAddress: "invalid@domain.com" }),
|
makeInput({ toAddress: "invalid@domain.com" }),
|
||||||
env as any,
|
env as any,
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(400);
|
expect(res).toMatchObject({ ok: false, reason: "invalid_address" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 when feed does not exist", async () => {
|
it("returns 404 when feed does not exist", async () => {
|
||||||
const res = await processEmail(makeInput(), env as any);
|
const res = await processEmail(makeInput(), env as any);
|
||||||
expect(res.status).toBe(404);
|
expect(res).toMatchObject({ ok: false, reason: "feed_not_found" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 403 when sender is not in allowlist", async () => {
|
it("returns 403 when sender is not in allowlist", async () => {
|
||||||
@@ -53,7 +56,7 @@ describe("processEmail", () => {
|
|||||||
makeInput({ senders: ["other@example.com"] }),
|
makeInput({ senders: ["other@example.com"] }),
|
||||||
env as any,
|
env as any,
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(403);
|
expect(res).toMatchObject({ ok: false, reason: "sender_blocked" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 200 and stores email when sender is allowed by exact match", async () => {
|
it("returns 200 and stores email when sender is allowed by exact match", async () => {
|
||||||
@@ -62,7 +65,7 @@ describe("processEmail", () => {
|
|||||||
JSON.stringify({ allowed_senders: ["sender@example.com"] }),
|
JSON.stringify({ allowed_senders: ["sender@example.com"] }),
|
||||||
);
|
);
|
||||||
const res = await processEmail(makeInput(), env as any);
|
const res = await processEmail(makeInput(), env as any);
|
||||||
expect(res.status).toBe(200);
|
expect(res.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 200 and stores email when sender matches by domain", async () => {
|
it("returns 200 and stores email when sender matches by domain", async () => {
|
||||||
@@ -74,7 +77,7 @@ describe("processEmail", () => {
|
|||||||
makeInput({ senders: ["anyone@example.com"] }),
|
makeInput({ senders: ["anyone@example.com"] }),
|
||||||
env as any,
|
env as any,
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 200 when no allowlist is set", async () => {
|
it("returns 200 when no allowlist is set", async () => {
|
||||||
@@ -83,7 +86,7 @@ describe("processEmail", () => {
|
|||||||
JSON.stringify({ allowed_senders: [] }),
|
JSON.stringify({ allowed_senders: [] }),
|
||||||
);
|
);
|
||||||
const res = await processEmail(makeInput(), env as any);
|
const res = await processEmail(makeInput(), env as any);
|
||||||
expect(res.status).toBe(200);
|
expect(res.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 403 when sender is in blocklist by exact address", async () => {
|
it("returns 403 when sender is in blocklist by exact address", async () => {
|
||||||
@@ -92,7 +95,7 @@ describe("processEmail", () => {
|
|||||||
JSON.stringify({ blocked_senders: ["sender@example.com"] }),
|
JSON.stringify({ blocked_senders: ["sender@example.com"] }),
|
||||||
);
|
);
|
||||||
const res = await processEmail(makeInput(), env as any);
|
const res = await processEmail(makeInput(), env as any);
|
||||||
expect(res.status).toBe(403);
|
expect(res).toMatchObject({ ok: false, reason: "sender_blocked" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 403 when sender is in blocklist by domain", async () => {
|
it("returns 403 when sender is in blocklist by domain", async () => {
|
||||||
@@ -101,7 +104,7 @@ describe("processEmail", () => {
|
|||||||
JSON.stringify({ blocked_senders: ["example.com"] }),
|
JSON.stringify({ blocked_senders: ["example.com"] }),
|
||||||
);
|
);
|
||||||
const res = await processEmail(makeInput(), env as any);
|
const res = await processEmail(makeInput(), env as any);
|
||||||
expect(res.status).toBe(403);
|
expect(res).toMatchObject({ ok: false, reason: "sender_blocked" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 200 when sender is not in blocklist", async () => {
|
it("returns 200 when sender is not in blocklist", async () => {
|
||||||
@@ -110,7 +113,7 @@ describe("processEmail", () => {
|
|||||||
JSON.stringify({ blocked_senders: ["other@example.com"] }),
|
JSON.stringify({ blocked_senders: ["other@example.com"] }),
|
||||||
);
|
);
|
||||||
const res = await processEmail(makeInput(), env as any);
|
const res = await processEmail(makeInput(), env as any);
|
||||||
expect(res.status).toBe(200);
|
expect(res.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exact block takes precedence over domain allow", async () => {
|
it("exact block takes precedence over domain allow", async () => {
|
||||||
@@ -122,7 +125,7 @@ describe("processEmail", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const res = await processEmail(makeInput(), env as any);
|
const res = await processEmail(makeInput(), env as any);
|
||||||
expect(res.status).toBe(403);
|
expect(res).toMatchObject({ ok: false, reason: "sender_blocked" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exact allow overrides domain block (exception use case)", async () => {
|
it("exact allow overrides domain block (exception use case)", async () => {
|
||||||
@@ -134,7 +137,7 @@ describe("processEmail", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const res = await processEmail(makeInput(), env as any);
|
const res = await processEmail(makeInput(), env as any);
|
||||||
expect(res.status).toBe(200);
|
expect(res.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exact block takes precedence over exact allow", async () => {
|
it("exact block takes precedence over exact allow", async () => {
|
||||||
@@ -146,7 +149,7 @@ describe("processEmail", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const res = await processEmail(makeInput(), env as any);
|
const res = await processEmail(makeInput(), env as any);
|
||||||
expect(res.status).toBe(403);
|
expect(res).toMatchObject({ ok: false, reason: "sender_blocked" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores email data and updates metadata in KV", async () => {
|
it("stores email data and updates metadata in KV", async () => {
|
||||||
@@ -247,7 +250,7 @@ describe("processEmail", () => {
|
|||||||
makeInput({ subject: "New" }),
|
makeInput({ subject: "New" }),
|
||||||
tinyEnv as any,
|
tinyEnv as any,
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
const metadata = await env.EMAIL_STORAGE.get(
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
`feed:${VALID_FEED_ID}:metadata`,
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
@@ -302,7 +305,7 @@ describe("processEmail", () => {
|
|||||||
|
|
||||||
const res = await processEmail(makeInput(), env as any, ctx);
|
const res = await processEmail(makeInput(), env as any, ctx);
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.ok).toBe(true);
|
||||||
expect(waitUntilCalled).toBe(true);
|
expect(waitUntilCalled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -323,7 +326,7 @@ describe("processEmail", () => {
|
|||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(404);
|
expect(res).toMatchObject({ ok: false, reason: "feed_not_found" });
|
||||||
expect(waitUntilCalled).toBe(false);
|
expect(waitUntilCalled).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -348,7 +351,7 @@ describe("processEmail — attachments", () => {
|
|||||||
makeInput({ attachments: [pdfAttachment] }),
|
makeInput({ attachments: [pdfAttachment] }),
|
||||||
env as any,
|
env as any,
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
const metadata = await env.EMAIL_STORAGE.get(
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
`feed:${VALID_FEED_ID}:metadata`,
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
@@ -361,6 +364,32 @@ describe("processEmail — attachments", () => {
|
|||||||
expect(emailData.attachments).toBeUndefined();
|
expect(emailData.attachments).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips R2 upload when ATTACHMENTS_ENABLED is 'false' even with R2 bound", async () => {
|
||||||
|
const env = createMockEnv({ withR2: true });
|
||||||
|
(env as any).ATTACHMENTS_ENABLED = "false";
|
||||||
|
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.ok).toBe(true);
|
||||||
|
|
||||||
|
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();
|
||||||
|
expect((await mockR2.list()).objects).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("uploads attachments to R2 and stores AttachmentData in emailData", async () => {
|
it("uploads attachments to R2 and stores AttachmentData in emailData", async () => {
|
||||||
const env = createMockEnv({ withR2: true });
|
const env = createMockEnv({ withR2: true });
|
||||||
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
||||||
@@ -372,7 +401,7 @@ describe("processEmail — attachments", () => {
|
|||||||
makeInput({ attachments: [pdfAttachment] }),
|
makeInput({ attachments: [pdfAttachment] }),
|
||||||
env as any,
|
env as any,
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
const metadata = await env.EMAIL_STORAGE.get(
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
`feed:${VALID_FEED_ID}:metadata`,
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
@@ -408,6 +437,104 @@ describe("processEmail — attachments", () => {
|
|||||||
expect(typeof metadata.emails[0].attachmentIds[0]).toBe("string");
|
expect(typeof metadata.emails[0].attachmentIds[0]).toBe("string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classifies a cid-referenced image as inline, not a downloadable attachment", async () => {
|
||||||
|
const env = createMockEnv({ withR2: true });
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inlineImage: RawAttachment = {
|
||||||
|
filename: "logo.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
content: new TextEncoder().encode("PNG").buffer as ArrayBuffer,
|
||||||
|
contentId: "logo123",
|
||||||
|
};
|
||||||
|
|
||||||
|
await processEmail(
|
||||||
|
makeInput({
|
||||||
|
content: '<p>Hi</p><img src="cid:logo123"/>',
|
||||||
|
attachments: [inlineImage, pdfAttachment],
|
||||||
|
}),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
|
||||||
|
const inline = emailData.attachments.find(
|
||||||
|
(a: any) => a.filename === "logo.png",
|
||||||
|
);
|
||||||
|
const pdf = emailData.attachments.find(
|
||||||
|
(a: any) => a.filename === "report.pdf",
|
||||||
|
);
|
||||||
|
expect(inline.inline).toBe(true);
|
||||||
|
expect(pdf.inline).toBeUndefined();
|
||||||
|
|
||||||
|
// Metadata splits ids: the pdf is downloadable, the logo is inline-only.
|
||||||
|
expect(metadata.emails[0].attachmentIds).toEqual([pdf.id]);
|
||||||
|
expect(metadata.emails[0].inlineAttachmentIds).toEqual([inline.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes inline image R2 objects when a trimmed email had them", 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 oldKey = `feed:${VALID_FEED_ID}:111`;
|
||||||
|
const inlineId = "old-inline-uuid";
|
||||||
|
const oldEmail = JSON.stringify({
|
||||||
|
subject: "Old",
|
||||||
|
from: "a@b.com",
|
||||||
|
content: "x".repeat(200) + '<img src="cid:c"/>',
|
||||||
|
receivedAt: 111,
|
||||||
|
headers: {},
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: inlineId,
|
||||||
|
filename: "logo.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
size: 100,
|
||||||
|
contentId: "c",
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await env.EMAIL_STORAGE.put(oldKey, oldEmail);
|
||||||
|
await mockR2.put(inlineId, 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,
|
||||||
|
inlineAttachmentIds: [inlineId],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const tinyEnv = { ...env, FEED_MAX_SIZE_BYTES: "50" };
|
||||||
|
const res = await processEmail(
|
||||||
|
makeInput({ subject: "New" }),
|
||||||
|
tinyEnv as any,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(mockR2._has(inlineId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("deletes R2 objects when a trimmed email had attachments", async () => {
|
it("deletes R2 objects when a trimmed email had attachments", async () => {
|
||||||
const env = createMockEnv({ withR2: true });
|
const env = createMockEnv({ withR2: true });
|
||||||
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
||||||
@@ -461,9 +588,178 @@ describe("processEmail — attachments", () => {
|
|||||||
makeInput({ subject: "New" }),
|
makeInput({ subject: "New" }),
|
||||||
tinyEnv as any,
|
tinyEnv as any,
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
// Old attachment should be deleted from R2
|
// Old attachment should be deleted from R2
|
||||||
expect(mockR2._has(oldAttachmentId)).toBe(false);
|
expect(mockR2._has(oldAttachmentId)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("processEmail — monitoring counters", () => {
|
||||||
|
it("increments emails_received and sets last_email_at on success", async () => {
|
||||||
|
const env = createMockEnv();
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await processEmail(makeInput(), env as any);
|
||||||
|
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||||
|
expect(counters.emails_received).toBe(1);
|
||||||
|
expect(counters.emails_rejected).toBe(0);
|
||||||
|
expect(counters.last_email_at).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments emails_rejected when validation fails", async () => {
|
||||||
|
const env = createMockEnv();
|
||||||
|
|
||||||
|
// No feed config → 404 rejection
|
||||||
|
await processEmail(makeInput(), env as any);
|
||||||
|
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||||
|
expect(counters.emails_rejected).toBe(1);
|
||||||
|
expect(counters.emails_received).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processEmail — feed icon", () => {
|
||||||
|
let env: ReturnType<typeof createMockEnv>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
env = createMockEnv();
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the latest sender domain on the feed metadata", async () => {
|
||||||
|
await processEmail(
|
||||||
|
makeInput({ from: "News <news@github.com>" }),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadata = (await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
)) as { iconDomain?: string };
|
||||||
|
expect(metadata.iconDomain).toBe("github.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers a background favicon fetch via ctx.waitUntil", async () => {
|
||||||
|
let fetched = false;
|
||||||
|
server.use(
|
||||||
|
http.get("https://github.com/favicon.ico", () => {
|
||||||
|
fetched = true;
|
||||||
|
return new HttpResponse(new Uint8Array([1, 2, 3]), {
|
||||||
|
headers: { "Content-Type": "image/png" },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pending: Promise<unknown>[] = [];
|
||||||
|
const ctx = {
|
||||||
|
waitUntil: (p: Promise<unknown>) => pending.push(p),
|
||||||
|
passThroughOnException: () => {},
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
await processEmail(makeInput({ from: "news@github.com" }), env as any, ctx);
|
||||||
|
await Promise.all(pending);
|
||||||
|
|
||||||
|
expect(fetched).toBe(true);
|
||||||
|
expect(
|
||||||
|
await env.EMAIL_STORAGE.get(iconKey("github.com"), "json"),
|
||||||
|
).toMatchObject({ contentType: "image/png" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processEmail — unsubscribe capture", () => {
|
||||||
|
let env: ReturnType<typeof createMockEnv>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
env = createMockEnv();
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores the one-click unsubscribe URL on the feed metadata, keyed by sender", async () => {
|
||||||
|
await processEmail(
|
||||||
|
makeInput({
|
||||||
|
senders: ["news@example.com"],
|
||||||
|
headers: {
|
||||||
|
"list-unsubscribe": "<https://example.com/u?t=abc>",
|
||||||
|
"list-unsubscribe-post": "List-Unsubscribe=One-Click",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadata = (await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
)) as { unsubscribe?: Record<string, string> };
|
||||||
|
expect(metadata.unsubscribe).toEqual({
|
||||||
|
"news@example.com": "https://example.com/u?t=abc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one entry per sender and overwrites with the latest URL", async () => {
|
||||||
|
await processEmail(
|
||||||
|
makeInput({
|
||||||
|
senders: ["a@one.com"],
|
||||||
|
headers: {
|
||||||
|
"list-unsubscribe": "<https://one.com/u/1>",
|
||||||
|
"list-unsubscribe-post": "List-Unsubscribe=One-Click",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
await processEmail(
|
||||||
|
makeInput({
|
||||||
|
senders: ["b@two.com"],
|
||||||
|
headers: {
|
||||||
|
"list-unsubscribe": "<https://two.com/u/1>",
|
||||||
|
"list-unsubscribe-post": "List-Unsubscribe=One-Click",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
await processEmail(
|
||||||
|
makeInput({
|
||||||
|
senders: ["a@one.com"],
|
||||||
|
headers: {
|
||||||
|
"list-unsubscribe": "<https://one.com/u/2>",
|
||||||
|
"list-unsubscribe-post": "List-Unsubscribe=One-Click",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadata = (await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
)) as { unsubscribe?: Record<string, string> };
|
||||||
|
expect(metadata.unsubscribe).toEqual({
|
||||||
|
"a@one.com": "https://one.com/u/2",
|
||||||
|
"b@two.com": "https://two.com/u/1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not store anything without the one-click Post header", async () => {
|
||||||
|
await processEmail(
|
||||||
|
makeInput({
|
||||||
|
headers: { "list-unsubscribe": "<https://example.com/u/1>" },
|
||||||
|
}),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadata = (await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
)) as { unsubscribe?: Record<string, string> };
|
||||||
|
expect(metadata.unsubscribe).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { EmailParser } from "../domain/email-parser";
|
||||||
|
import { AttachmentData, EmailMetadata, Env } from "../types";
|
||||||
|
import { bumpCounters } from "../application/stats";
|
||||||
|
import { dispatchFeedEvents } from "../application/feed-events";
|
||||||
|
import { extractEmailDomain } from "../infrastructure/favicon-fetcher";
|
||||||
|
import { parseOneClickUnsubscribe } from "../infrastructure/unsubscribe";
|
||||||
|
import { getAttachmentBucket } from "../infrastructure/attachments";
|
||||||
|
import { extractInlineCids } from "../infrastructure/html-processor";
|
||||||
|
import { attachmentIdsForCleanup } from "./feed-cleanup";
|
||||||
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
|
import { BackgroundScheduler } from "../infrastructure/worker";
|
||||||
|
import { Feed } from "../domain/feed.aggregate";
|
||||||
|
import { logger } from "../infrastructure/logger";
|
||||||
|
import { FEED_MAX_BYTES } from "../config/constants";
|
||||||
|
|
||||||
|
export interface RawAttachment {
|
||||||
|
filename: string;
|
||||||
|
contentType: string;
|
||||||
|
content: ArrayBuffer;
|
||||||
|
contentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessEmailInput {
|
||||||
|
toAddress: string;
|
||||||
|
from: string;
|
||||||
|
senders: string[];
|
||||||
|
subject: string;
|
||||||
|
content: string;
|
||||||
|
receivedAt: number;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
attachments?: RawAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IngestRejectionReason =
|
||||||
|
| "invalid_address"
|
||||||
|
| "feed_not_found"
|
||||||
|
| "feed_expired"
|
||||||
|
| "sender_blocked";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of ingesting an email — a domain result, not an HTTP concern. The edge
|
||||||
|
* (forwardemail.ts) maps this to a status code; the Cloudflare email handler
|
||||||
|
* logs the reason. Keeping HTTP out of the core keeps ingestion transport-agnostic.
|
||||||
|
*/
|
||||||
|
export type IngestResult =
|
||||||
|
| { ok: true; feedId: string }
|
||||||
|
| { ok: false; reason: IngestRejectionReason };
|
||||||
|
|
||||||
|
async function uploadAttachments(
|
||||||
|
attachments: RawAttachment[],
|
||||||
|
bucket: R2Bucket,
|
||||||
|
inlineCids: Set<string>,
|
||||||
|
): Promise<AttachmentData[]> {
|
||||||
|
return Promise.all(
|
||||||
|
attachments.map(async (att) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const inline = att.contentId ? inlineCids.has(att.contentId) : false;
|
||||||
|
await bucket.put(id, att.content, {
|
||||||
|
httpMetadata: {
|
||||||
|
contentType: att.contentType,
|
||||||
|
contentDisposition: `${inline ? "inline" : "attachment"}; filename="${att.filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
filename: att.filename,
|
||||||
|
contentType: att.contentType,
|
||||||
|
size: att.content.byteLength,
|
||||||
|
...(att.contentId ? { contentId: att.contentId } : {}),
|
||||||
|
...(inline ? { inline: true } : {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAcceptingFeed(
|
||||||
|
input: ProcessEmailInput,
|
||||||
|
env: Env,
|
||||||
|
): Promise<
|
||||||
|
{ ok: true; feed: Feed } | { ok: false; reason: IngestRejectionReason }
|
||||||
|
> {
|
||||||
|
const feedId = EmailParser.extractFeedId(input.toAddress);
|
||||||
|
if (!feedId) {
|
||||||
|
logger.error("Invalid email address format", {
|
||||||
|
toAddress: input.toAddress,
|
||||||
|
});
|
||||||
|
return { ok: false, reason: "invalid_address" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const feed = await FeedRepository.from(env).load(feedId);
|
||||||
|
if (!feed) {
|
||||||
|
logger.error("Feed not found", { feedId: feedId.value });
|
||||||
|
return { ok: false, reason: "feed_not_found" };
|
||||||
|
}
|
||||||
|
if (feed.isExpired()) {
|
||||||
|
logger.warn("Rejected email: feed expired", { feedId: feedId.value });
|
||||||
|
return { ok: false, reason: "feed_expired" };
|
||||||
|
}
|
||||||
|
if (feed.accepts(input.senders) === "blocked") {
|
||||||
|
logger.warn("Rejected email: sender filter", {
|
||||||
|
feedId: feedId.value,
|
||||||
|
senders: input.senders,
|
||||||
|
allowedSenders: feed.allowedSenders(),
|
||||||
|
blockedSenders: feed.blockedSenders(),
|
||||||
|
});
|
||||||
|
return { ok: false, reason: "sender_blocked" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, feed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeEmail(
|
||||||
|
feed: Feed,
|
||||||
|
input: ProcessEmailInput,
|
||||||
|
env: Env,
|
||||||
|
ctx?: ExecutionContext,
|
||||||
|
): Promise<void> {
|
||||||
|
const attachmentBucket = getAttachmentBucket(env);
|
||||||
|
const inlineCids = extractInlineCids(input.content);
|
||||||
|
const storedAttachments: AttachmentData[] =
|
||||||
|
attachmentBucket && input.attachments?.length
|
||||||
|
? await uploadAttachments(input.attachments, attachmentBucket, inlineCids)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const emailData = {
|
||||||
|
subject: input.subject,
|
||||||
|
from: input.from,
|
||||||
|
content: input.content,
|
||||||
|
receivedAt: input.receivedAt,
|
||||||
|
headers: input.headers ?? {},
|
||||||
|
...(storedAttachments.length > 0 ? { attachments: storedAttachments } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
const emailKey = repo.newEmailKey(feed.id);
|
||||||
|
await repo.putEmail(emailKey, emailData);
|
||||||
|
|
||||||
|
const serialisedSize = new TextEncoder().encode(
|
||||||
|
JSON.stringify(emailData),
|
||||||
|
).byteLength;
|
||||||
|
const downloadableIds = storedAttachments
|
||||||
|
.filter((a) => !a.inline)
|
||||||
|
.map((a) => a.id);
|
||||||
|
const inlineIds = storedAttachments.filter((a) => a.inline).map((a) => a.id);
|
||||||
|
const newEntry: EmailMetadata = {
|
||||||
|
key: emailKey,
|
||||||
|
subject: emailData.subject,
|
||||||
|
receivedAt: emailData.receivedAt,
|
||||||
|
size: serialisedSize,
|
||||||
|
...(downloadableIds.length > 0 ? { attachmentIds: downloadableIds } : {}),
|
||||||
|
...(inlineIds.length > 0 ? { inlineAttachmentIds: inlineIds } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track the latest sender's domain (feed icon) and capture the RFC 8058
|
||||||
|
// one-click unsubscribe link, keyed by sender so each newsletter keeps its
|
||||||
|
// own latest URL (fired when the feed is deleted).
|
||||||
|
const iconDomain = extractEmailDomain(input.from);
|
||||||
|
const unsubUrl = parseOneClickUnsubscribe(input.headers ?? {});
|
||||||
|
const unsub = unsubUrl
|
||||||
|
? {
|
||||||
|
senderKey: input.senders[0] || iconDomain || input.from,
|
||||||
|
url: unsubUrl,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const maxBytes =
|
||||||
|
parseInt(env.FEED_MAX_SIZE_BYTES ?? "", 10) || FEED_MAX_BYTES;
|
||||||
|
|
||||||
|
const { dropped } = feed.ingest(newEntry, {
|
||||||
|
maxBytes,
|
||||||
|
iconDomain: iconDomain ?? undefined,
|
||||||
|
unsub,
|
||||||
|
});
|
||||||
|
|
||||||
|
const r2Deletions =
|
||||||
|
attachmentBucket && dropped.length > 0
|
||||||
|
? dropped
|
||||||
|
.flatMap((e) => attachmentIdsForCleanup(e))
|
||||||
|
.map((id) => attachmentBucket.delete(id))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// KV has no compare-and-swap: the load (in loadAcceptingFeed) and this write
|
||||||
|
// are not serialised, so concurrent ingests for one feed can lose updates.
|
||||||
|
// Accepted under KV's eventual-consistency model; the Feed aggregate is the
|
||||||
|
// seam a Durable Object would later wrap to serialise these writers.
|
||||||
|
await Promise.all([
|
||||||
|
repo.saveMetadata(feed),
|
||||||
|
...dropped.map((e) => repo.deleteEmail(e.key)),
|
||||||
|
...r2Deletions,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("Email processed", { feedId: feed.id.value });
|
||||||
|
|
||||||
|
// The aggregate recorded an EmailIngested event; the dispatcher applies its
|
||||||
|
// side effects (received counter, WebSub ping, favicon fetch). Background work
|
||||||
|
// rides on ctx.waitUntil when present, and is skipped in its absence (tests).
|
||||||
|
const schedule: BackgroundScheduler = ctx
|
||||||
|
? (p) => ctx.waitUntil(p)
|
||||||
|
: () => {};
|
||||||
|
await dispatchFeedEvents(feed, env, schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processEmail(
|
||||||
|
input: ProcessEmailInput,
|
||||||
|
env: Env,
|
||||||
|
ctx?: ExecutionContext,
|
||||||
|
): Promise<IngestResult> {
|
||||||
|
const validation = await loadAcceptingFeed(input, env);
|
||||||
|
if (!validation.ok) {
|
||||||
|
await bumpCounters(env.EMAIL_STORAGE, { emails_rejected: 1 });
|
||||||
|
return validation;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storeEmail(validation.feed, input, env, ctx);
|
||||||
|
return { ok: true, feedId: validation.feed.id.value };
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { EmailData, EmailMetadata, Env } from "../types";
|
||||||
|
import { logger } from "../infrastructure/logger";
|
||||||
|
import { getAttachmentBucket } from "../infrastructure/attachments";
|
||||||
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
|
||||||
|
// All R2 object ids an email owns — both downloadable attachments and inline
|
||||||
|
// images. Inline images are hidden from the user-facing lists but must still be
|
||||||
|
// purged from the bucket when the email is deleted.
|
||||||
|
export function attachmentIdsForCleanup(e: EmailMetadata): string[] {
|
||||||
|
return [...(e.attachmentIds ?? []), ...(e.inlineAttachmentIds ?? [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the R2 attachments belonging to the given email keys. Call before the
|
||||||
|
// emails are removed from feed metadata, while `emails` still carries their
|
||||||
|
// attachment ids.
|
||||||
|
export async function deleteAttachmentsForEmails(
|
||||||
|
env: Env,
|
||||||
|
emails: readonly EmailMetadata[],
|
||||||
|
keys: Iterable<string>,
|
||||||
|
): Promise<void> {
|
||||||
|
const keySet = new Set(keys);
|
||||||
|
const attachmentIds = emails
|
||||||
|
.filter((e) => keySet.has(e.key))
|
||||||
|
.flatMap((e) => attachmentIdsForCleanup(e));
|
||||||
|
if (attachmentIds.length === 0) return;
|
||||||
|
|
||||||
|
const bucket = getAttachmentBucket(env);
|
||||||
|
if (!bucket) return;
|
||||||
|
|
||||||
|
await Promise.allSettled(attachmentIds.map((id) => bucket.delete(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a feed's stored RFC 8058 one-click unsubscribe URLs (one per sender).
|
||||||
|
* Must be called before the feed metadata is deleted. Never throws.
|
||||||
|
*/
|
||||||
|
export async function collectUnsubscribeUrls(
|
||||||
|
emailStorage: KVNamespace,
|
||||||
|
feedId: FeedId,
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const metadata = await new FeedRepository(emailStorage).getMetadata(feedId);
|
||||||
|
return Object.values(metadata?.unsubscribe ?? {});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error reading unsubscribe URLs", {
|
||||||
|
feedId: feedId.value,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purgeFeedKeysStep(
|
||||||
|
emailStorage: KVNamespace,
|
||||||
|
feedId: FeedId,
|
||||||
|
options: { cursor?: string; limit?: number; bucket?: R2Bucket } = {},
|
||||||
|
): Promise<{
|
||||||
|
deletedKeys: string[];
|
||||||
|
failedKeys: string[];
|
||||||
|
cursor: string;
|
||||||
|
listComplete: boolean;
|
||||||
|
}> {
|
||||||
|
const repo = new FeedRepository(emailStorage);
|
||||||
|
const listed = await repo.listFeedKeys(feedId, {
|
||||||
|
cursor: options.cursor,
|
||||||
|
limit: options.limit,
|
||||||
|
});
|
||||||
|
const keys = listed.names;
|
||||||
|
|
||||||
|
if (options.bucket && keys.length > 0) {
|
||||||
|
const emailKeys = keys.filter((k) => repo.isEmailKey(feedId, k));
|
||||||
|
if (emailKeys.length > 0) {
|
||||||
|
const emailDataResults = await Promise.allSettled(
|
||||||
|
emailKeys.map((k) => repo.getEmail(k)),
|
||||||
|
);
|
||||||
|
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.listComplete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purgeExpiredFeeds(
|
||||||
|
emailStorage: KVNamespace,
|
||||||
|
feedId: FeedId,
|
||||||
|
bucket?: R2Bucket,
|
||||||
|
): Promise<void> {
|
||||||
|
let cursor: string | undefined;
|
||||||
|
do {
|
||||||
|
const step = await purgeFeedKeysStep(emailStorage, feedId, {
|
||||||
|
bucket,
|
||||||
|
limit: 100,
|
||||||
|
cursor,
|
||||||
|
});
|
||||||
|
cursor = step.listComplete ? undefined : step.cursor;
|
||||||
|
} while (cursor);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Env } from "../types";
|
||||||
|
import { FeedEvent } from "../domain/events";
|
||||||
|
import { Feed } from "../domain/feed.aggregate";
|
||||||
|
import { BackgroundScheduler } from "../infrastructure/worker";
|
||||||
|
import { bumpCounters } from "./stats";
|
||||||
|
import { notifySubscribers } from "../infrastructure/websub";
|
||||||
|
import { cacheFaviconForDomain } from "../infrastructure/favicon-fetcher";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the side effects of a feed's domain events — the single place that maps
|
||||||
|
* "what happened" (FeedCreated, EmailIngested) to its consequences. Each event
|
||||||
|
* carries its own `feedId`, so nothing has to be threaded in. Counter writes are
|
||||||
|
* awaited (they must land); WebSub pings and favicon fetches are handed to the
|
||||||
|
* caller's background scheduler (`ctx.waitUntil` at the edge, a no-op when none
|
||||||
|
* is available).
|
||||||
|
*/
|
||||||
|
export async function applyFeedEvents(
|
||||||
|
events: FeedEvent[],
|
||||||
|
env: Env,
|
||||||
|
schedule: BackgroundScheduler,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const event of events) {
|
||||||
|
switch (event.type) {
|
||||||
|
case "FeedCreated":
|
||||||
|
await bumpCounters(env.EMAIL_STORAGE, {
|
||||||
|
feeds_created: 1,
|
||||||
|
last_feed_created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "EmailIngested":
|
||||||
|
await bumpCounters(env.EMAIL_STORAGE, {
|
||||||
|
emails_received: 1,
|
||||||
|
last_email_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
schedule(notifySubscribers(event.feedId, env));
|
||||||
|
if (event.iconDomain) {
|
||||||
|
schedule(cacheFaviconForDomain(event.iconDomain, env));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain a freshly-persisted aggregate's events and apply their side effects. The
|
||||||
|
* single dispatch entry point: callers persist the `Feed`, then call this — no
|
||||||
|
* caller pulls events or passes the feed id by hand.
|
||||||
|
*/
|
||||||
|
export async function dispatchFeedEvents(
|
||||||
|
feed: Feed,
|
||||||
|
env: Env,
|
||||||
|
schedule: BackgroundScheduler,
|
||||||
|
): Promise<void> {
|
||||||
|
await applyFeedEvents(feed.pullEvents(), env, schedule);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
|
import { Env, FeedConfig, EmailData } from "../types";
|
||||||
import { MAX_FEED_ITEMS } from "../config/constants";
|
import { MAX_FEED_ITEMS } from "../config/constants";
|
||||||
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
|
||||||
export interface FeedData {
|
export interface FeedData {
|
||||||
feedConfig: FeedConfig;
|
feedConfig: FeedConfig;
|
||||||
@@ -7,23 +9,16 @@ export interface FeedData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchFeedData(
|
export async function fetchFeedData(
|
||||||
feedId: string,
|
feedId: FeedId,
|
||||||
env: Env,
|
env: Env,
|
||||||
): Promise<FeedData | null> {
|
): Promise<FeedData | null> {
|
||||||
const storage = env.EMAIL_STORAGE;
|
const repo = FeedRepository.from(env);
|
||||||
|
|
||||||
const feedMetadata = (await storage.get(
|
|
||||||
`feed:${feedId}:metadata`,
|
|
||||||
"json",
|
|
||||||
)) as FeedMetadata | null;
|
|
||||||
|
|
||||||
|
const feedMetadata = await repo.getMetadata(feedId);
|
||||||
if (!feedMetadata) return null;
|
if (!feedMetadata) return null;
|
||||||
|
|
||||||
const feedConfig = ((await storage.get(
|
const feedConfig = (await repo.getConfig(feedId)) ?? {
|
||||||
`feed:${feedId}:config`,
|
title: `Newsletter Feed ${feedId.value}`,
|
||||||
"json",
|
|
||||||
)) as FeedConfig | null) ?? {
|
|
||||||
title: `Newsletter Feed ${feedId}`,
|
|
||||||
description: "Converted email newsletter",
|
description: "Converted email newsletter",
|
||||||
language: "en",
|
language: "en",
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
@@ -32,7 +27,7 @@ export async function fetchFeedData(
|
|||||||
const emailRefs = feedMetadata.emails.slice(0, MAX_FEED_ITEMS);
|
const emailRefs = feedMetadata.emails.slice(0, MAX_FEED_ITEMS);
|
||||||
const emails: EmailData[] = [];
|
const emails: EmailData[] = [];
|
||||||
for (const ref of emailRefs) {
|
for (const ref of emailRefs) {
|
||||||
const data = (await storage.get(ref.key, "json")) as EmailData | null;
|
const data = await repo.getEmail(ref.key);
|
||||||
if (data) emails.push(data);
|
if (data) emails.push(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createMockEnv } from "../test/setup";
|
||||||
|
import { createFeedRecord, editFeed } from "./feed-service";
|
||||||
|
import { getCounters } from "./stats";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import type { Env } from "../types";
|
||||||
|
|
||||||
|
const mkEnv = (overrides: Partial<Env> = {}) =>
|
||||||
|
({ ...createMockEnv(), ...overrides }) as unknown as Env;
|
||||||
|
|
||||||
|
const baseInput = {
|
||||||
|
title: "N",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const TWO_HOURS = 2 * 3_600_000;
|
||||||
|
|
||||||
|
// The lifetime policy (parse env, apply the server-side FEED_TTL_HOURS override)
|
||||||
|
// lives here in the application layer; the domain only receives a resolved
|
||||||
|
// ttlHours. These tests pin that policy at the public service boundary.
|
||||||
|
describe("createFeedRecord — TTL policy", () => {
|
||||||
|
it("never expires when neither server nor client lifetime is set", async () => {
|
||||||
|
const { config } = await createFeedRecord(mkEnv(), { ...baseInput });
|
||||||
|
expect(config.expires_at).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the client lifetimeHours when there is no server override", async () => {
|
||||||
|
const before = Date.now();
|
||||||
|
const { config } = await createFeedRecord(mkEnv(), {
|
||||||
|
...baseInput,
|
||||||
|
lifetimeHours: 2,
|
||||||
|
});
|
||||||
|
expect(config.expires_at!).toBeGreaterThanOrEqual(before + TWO_HOURS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a server FEED_TTL_HOURS override a larger client lifetime", async () => {
|
||||||
|
const before = Date.now();
|
||||||
|
const { config } = await createFeedRecord(mkEnv({ FEED_TTL_HOURS: "1" }), {
|
||||||
|
...baseInput,
|
||||||
|
lifetimeHours: 9999,
|
||||||
|
});
|
||||||
|
// 1h (server) wins over 9999h (client).
|
||||||
|
expect(config.expires_at!).toBeLessThan(before + TWO_HOURS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bumps the feeds_created counter via the FeedCreated domain event", async () => {
|
||||||
|
const env = mkEnv();
|
||||||
|
await createFeedRecord(env, { ...baseInput });
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE);
|
||||||
|
expect(counters.feeds_created).toBe(1);
|
||||||
|
expect(counters.last_feed_created_at).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editFeed — TTL policy", () => {
|
||||||
|
it("recomputes expiry from the server override on edit", async () => {
|
||||||
|
const env = mkEnv({ FEED_TTL_HOURS: "1" });
|
||||||
|
const { feedId } = await createFeedRecord(env, { ...baseInput });
|
||||||
|
|
||||||
|
const before = Date.now();
|
||||||
|
const result = await editFeed(env, FeedId.unchecked(feedId), {
|
||||||
|
title: "renamed",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("ok");
|
||||||
|
if (result.status === "ok") {
|
||||||
|
expect(result.config.title).toBe("renamed");
|
||||||
|
expect(result.config.expires_at!).toBeLessThan(before + TWO_HOURS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves expiry when neither server TTL nor client lifetime is given", async () => {
|
||||||
|
const env = mkEnv();
|
||||||
|
const { feedId, config } = await createFeedRecord(env, {
|
||||||
|
...baseInput,
|
||||||
|
lifetimeHours: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await editFeed(env, FeedId.unchecked(feedId), {
|
||||||
|
title: "x",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("ok");
|
||||||
|
if (result.status === "ok") {
|
||||||
|
expect(result.config.expires_at).toBe(config.expires_at);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { Env, FeedConfig } from "../types";
|
||||||
|
import { bumpCounters } from "../application/stats";
|
||||||
|
import { dispatchFeedEvents } from "./feed-events";
|
||||||
|
import { sendUnsubscribes } from "../infrastructure/unsubscribe";
|
||||||
|
import { getAttachmentBucket } from "../infrastructure/attachments";
|
||||||
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
|
import { toConfigDTO } from "../infrastructure/feed-mapper";
|
||||||
|
import { BackgroundScheduler } from "../infrastructure/worker";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import { Lifetime } from "../domain/value-objects/lifetime";
|
||||||
|
import {
|
||||||
|
Feed,
|
||||||
|
CreateFeedInput,
|
||||||
|
UpdateFeedInput,
|
||||||
|
} from "../domain/feed.aggregate";
|
||||||
|
import { purgeFeedKeysStep, collectUnsubscribeUrls } from "./feed-cleanup";
|
||||||
|
|
||||||
|
export type { CreateFeedInput, UpdateFeedInput };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective feed `Lifetime` from a client request and the
|
||||||
|
* server-side `FEED_TTL_HOURS` override. Parsing the env string and applying the
|
||||||
|
* override is application/config policy — the domain only receives the resolved
|
||||||
|
* VO. Returns `Lifetime.never` when the feed should never expire.
|
||||||
|
*/
|
||||||
|
function resolveLifetime(env: Env, requestedHours?: number): Lifetime {
|
||||||
|
const hours = env.FEED_TTL_HOURS
|
||||||
|
? parseInt(env.FEED_TTL_HOURS, 10)
|
||||||
|
: (requestedHours ?? NaN);
|
||||||
|
return Number.isFinite(hours) && hours > 0
|
||||||
|
? Lifetime.ofHours(hours)
|
||||||
|
: Lifetime.never;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a feed: write its config + empty metadata, register it in the global
|
||||||
|
* list, and bump the `feeds_created` counter. Returns the new feed id + config.
|
||||||
|
*/
|
||||||
|
export async function createFeedRecord(
|
||||||
|
env: Env,
|
||||||
|
input: CreateFeedInput,
|
||||||
|
): Promise<{ feedId: string; config: FeedConfig }> {
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
const feed = Feed.create(FeedId.generate(), input, {
|
||||||
|
lifetime: resolveLifetime(env, input.lifetimeHours),
|
||||||
|
});
|
||||||
|
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
// FeedCreated → bumps the feeds_created counter (no background work to schedule).
|
||||||
|
await dispatchFeedEvents(feed, env, () => {});
|
||||||
|
|
||||||
|
return { feedId: feed.id.value, config: toConfigDTO(feed.state()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateFeedResult =
|
||||||
|
| { status: "ok"; config: FeedConfig }
|
||||||
|
| { status: "not_found" }
|
||||||
|
| { status: "expired" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick-edit of title/description only — never recomputes expiry. Used by the
|
||||||
|
* dashboard's minimal edit. Delegates to the aggregate's single `edit` path, so
|
||||||
|
* an expired feed is rejected here too. The list projection is kept in sync by
|
||||||
|
* the repository on `saveConfig`.
|
||||||
|
*/
|
||||||
|
export async function editFeedDetails(
|
||||||
|
env: Env,
|
||||||
|
feedId: FeedId,
|
||||||
|
patch: { title?: string; description?: string },
|
||||||
|
): Promise<UpdateFeedResult> {
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
const feed = await repo.load(feedId);
|
||||||
|
if (!feed) return { status: "not_found" };
|
||||||
|
|
||||||
|
// No lifetime passed ⇒ expiry preserved (quick-edit never recomputes it).
|
||||||
|
if (feed.edit(patch).status === "expired") {
|
||||||
|
return { status: "expired" };
|
||||||
|
}
|
||||||
|
await repo.saveConfig(feed);
|
||||||
|
|
||||||
|
return { status: "ok", config: toConfigDTO(feed.state()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full edit: apply the patch, recompute expiry, and reject expired feeds. Fields
|
||||||
|
* left undefined are preserved. Mirrors title/description/expiry into the list.
|
||||||
|
*/
|
||||||
|
export async function editFeed(
|
||||||
|
env: Env,
|
||||||
|
feedId: FeedId,
|
||||||
|
input: UpdateFeedInput,
|
||||||
|
): Promise<UpdateFeedResult> {
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
const feed = await repo.load(feedId);
|
||||||
|
if (!feed) return { status: "not_found" };
|
||||||
|
|
||||||
|
// Recompute expiry only when a server TTL or a client lifetime applies;
|
||||||
|
// otherwise pass no lifetime so the aggregate preserves the current expiry.
|
||||||
|
const lifetime =
|
||||||
|
Boolean(env.FEED_TTL_HOURS) || input.lifetimeHours !== undefined
|
||||||
|
? resolveLifetime(env, input.lifetimeHours)
|
||||||
|
: undefined;
|
||||||
|
if (feed.edit(input, { lifetime }).status === "expired") {
|
||||||
|
return { status: "expired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.saveConfig(feed);
|
||||||
|
|
||||||
|
return { status: "ok", config: toConfigDTO(feed.state()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteFeedFastResult = {
|
||||||
|
ok: boolean;
|
||||||
|
configDeleted: boolean;
|
||||||
|
metadataDeleted: boolean;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a feed's config + metadata keys, reporting per-key outcomes. The
|
||||||
|
* larger email/attachment cleanup is handled separately via purgeFeedKeysStep.
|
||||||
|
*/
|
||||||
|
export async function deleteFeedFastDetailed(
|
||||||
|
emailStorage: KVNamespace,
|
||||||
|
feedId: FeedId,
|
||||||
|
): Promise<DeleteFeedFastResult> {
|
||||||
|
const repo = new FeedRepository(emailStorage);
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
let configDeleted = false;
|
||||||
|
let metadataDeleted = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await repo.deleteConfig(feedId);
|
||||||
|
configDeleted = true;
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`config delete failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await repo.deleteMetadata(feedId);
|
||||||
|
metadataDeleted = true;
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`metadata delete failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: configDeleted, configDeleted, metadataDeleted, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single feed end-to-end: capture unsubscribe URLs, drop its config +
|
||||||
|
* metadata, remove it from the list, bump the counter, and hand the background
|
||||||
|
* unsubscribe requests + key purge to the supplied scheduler. Returns whether
|
||||||
|
* the feed was present in the global list.
|
||||||
|
*/
|
||||||
|
export async function deleteFeedRecord(
|
||||||
|
env: Env,
|
||||||
|
feedId: FeedId,
|
||||||
|
schedule: BackgroundScheduler,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
|
const repo = new FeedRepository(emailStorage);
|
||||||
|
|
||||||
|
// Read unsubscribe URLs before the metadata is deleted below.
|
||||||
|
const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId);
|
||||||
|
|
||||||
|
await deleteFeedFastDetailed(emailStorage, feedId);
|
||||||
|
const removed = await repo.removeFromList(feedId);
|
||||||
|
if (removed) {
|
||||||
|
await bumpCounters(emailStorage, { feeds_deleted: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unsubscribeUrls.length > 0) {
|
||||||
|
schedule(sendUnsubscribes(unsubscribeUrls, env));
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule(
|
||||||
|
purgeFeedKeysStep(emailStorage, feedId, {
|
||||||
|
bucket: getAttachmentBucket(env),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createMockEnv, MockR2 } from "../test/setup";
|
||||||
|
import {
|
||||||
|
getCounters,
|
||||||
|
bumpCounters,
|
||||||
|
countKeysByPrefix,
|
||||||
|
getStats,
|
||||||
|
scanR2Usage,
|
||||||
|
scanKvUsage,
|
||||||
|
setStorageSnapshot,
|
||||||
|
} from "./stats";
|
||||||
|
import { getAttachmentBucket } from "../infrastructure/attachments";
|
||||||
|
import { STATS_KEY, FEEDS_LIST_KEY } from "../config/constants";
|
||||||
|
import { Env } from "../types";
|
||||||
|
|
||||||
|
describe("stats helper", () => {
|
||||||
|
it("returns zeroed counters when nothing is stored", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE);
|
||||||
|
expect(counters).toMatchObject({
|
||||||
|
feeds_created: 0,
|
||||||
|
feeds_deleted: 0,
|
||||||
|
emails_received: 0,
|
||||||
|
emails_rejected: 0,
|
||||||
|
});
|
||||||
|
expect(counters.first_seen).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates numeric deltas across bumps", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
|
||||||
|
await bumpCounters(kv, { emails_received: 1 });
|
||||||
|
await bumpCounters(kv, { emails_received: 2, emails_rejected: 1 });
|
||||||
|
await bumpCounters(kv, { feeds_created: 1, feeds_deleted: 3 });
|
||||||
|
|
||||||
|
const counters = await getCounters(kv);
|
||||||
|
expect(counters.emails_received).toBe(3);
|
||||||
|
expect(counters.emails_rejected).toBe(1);
|
||||||
|
expect(counters.feeds_created).toBe(1);
|
||||||
|
expect(counters.feeds_deleted).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrites date-time fields and sets first_seen once", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
|
||||||
|
await bumpCounters(kv, {
|
||||||
|
emails_received: 1,
|
||||||
|
last_email_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
const first = await getCounters(kv);
|
||||||
|
const firstSeen = first.first_seen;
|
||||||
|
expect(firstSeen).toBeDefined();
|
||||||
|
expect(first.last_email_at).toBe("2026-01-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
await bumpCounters(kv, {
|
||||||
|
emails_received: 1,
|
||||||
|
last_email_at: "2026-02-02T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
const second = await getCounters(kv);
|
||||||
|
expect(second.last_email_at).toBe("2026-02-02T00:00:00.000Z");
|
||||||
|
expect(second.first_seen).toBe(firstSeen);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts keys by prefix", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
await kv.put("websub:a:1", "{}");
|
||||||
|
await kv.put("websub:a:2", "{}");
|
||||||
|
await kv.put("feed:x:config", "{}");
|
||||||
|
|
||||||
|
expect(await countKeysByPrefix(kv, "websub:")).toBe(2);
|
||||||
|
expect(await countKeysByPrefix(kv, "missing:")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getStats combines persisted counters with live values", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
|
||||||
|
await kv.put(
|
||||||
|
FEEDS_LIST_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
feeds: [
|
||||||
|
{ id: "a", title: "A" },
|
||||||
|
{ id: "b", title: "B" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await kv.put("websub:subs:a", "{}");
|
||||||
|
await bumpCounters(kv, { emails_received: 5, feeds_created: 2 });
|
||||||
|
|
||||||
|
const stats = await getStats(env);
|
||||||
|
expect(stats.active_feeds).toBe(2);
|
||||||
|
expect(stats.websub_subscriptions_active).toBe(1);
|
||||||
|
expect(stats.emails_received).toBe(5);
|
||||||
|
expect(stats.feeds_created).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on a failing KV (counters are best-effort)", async () => {
|
||||||
|
const brokenKv = {
|
||||||
|
get: async () => {
|
||||||
|
throw new Error("kv down");
|
||||||
|
},
|
||||||
|
put: async () => {
|
||||||
|
throw new Error("kv down");
|
||||||
|
},
|
||||||
|
} as unknown as KVNamespace;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bumpCounters(brokenKv, { emails_received: 1 }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(await getCounters(brokenKv)).toMatchObject({ emails_received: 0 });
|
||||||
|
expect(await countKeysByPrefix(brokenKv, "websub:")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists under the stats KV key", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
await bumpCounters(kv, { feeds_created: 1 });
|
||||||
|
const raw = (await kv.get(STATS_KEY, { type: "json" })) as {
|
||||||
|
feeds_created: number;
|
||||||
|
};
|
||||||
|
expect(raw.feeds_created).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getStats reports attachments_enabled based on the toggle", async () => {
|
||||||
|
const off = createMockEnv() as unknown as Env;
|
||||||
|
expect((await getStats(off)).attachments_enabled).toBe(false);
|
||||||
|
|
||||||
|
const on = createMockEnv({ withR2: true }) as unknown as Env;
|
||||||
|
expect((await getStats(on)).attachments_enabled).toBe(true);
|
||||||
|
|
||||||
|
const disabled = createMockEnv({ withR2: true }) as unknown as Env;
|
||||||
|
(disabled as any).ATTACHMENTS_ENABLED = "false";
|
||||||
|
expect((await getStats(disabled)).attachments_enabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAttachmentBucket", () => {
|
||||||
|
it("returns the bucket when bound and not disabled", () => {
|
||||||
|
const env = createMockEnv({ withR2: true }) as unknown as Env;
|
||||||
|
expect(getAttachmentBucket(env)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no bucket is bound", () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
expect(getAttachmentBucket(env)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when explicitly disabled", () => {
|
||||||
|
const env = createMockEnv({ withR2: true }) as unknown as Env;
|
||||||
|
(env as any).ATTACHMENTS_ENABLED = "false";
|
||||||
|
expect(getAttachmentBucket(env)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storage usage scans", () => {
|
||||||
|
it("scanR2Usage sums object sizes and counts", async () => {
|
||||||
|
const bucket = new MockR2();
|
||||||
|
await bucket.put("a", new Uint8Array(100));
|
||||||
|
await bucket.put("b", new Uint8Array(250));
|
||||||
|
const usage = await scanR2Usage(bucket as unknown as R2Bucket);
|
||||||
|
expect(usage.count).toBe(2);
|
||||||
|
expect(usage.bytes).toBe(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scanR2Usage returns zeros for an empty bucket", async () => {
|
||||||
|
const usage = await scanR2Usage(new MockR2() as unknown as R2Bucket);
|
||||||
|
expect(usage).toEqual({ bytes: 0, count: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scanKvUsage estimates KV bytes from stored email sizes", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
await kv.put(
|
||||||
|
FEEDS_LIST_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
feeds: [
|
||||||
|
{ id: "a", title: "A" },
|
||||||
|
{ id: "b", title: "B" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await kv.put(
|
||||||
|
"feed:a:metadata",
|
||||||
|
JSON.stringify({
|
||||||
|
emails: [
|
||||||
|
{ key: "k1", size: 100 },
|
||||||
|
{ key: "k2", size: 50 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await kv.put(
|
||||||
|
"feed:b:metadata",
|
||||||
|
JSON.stringify({ emails: [{ key: "k3", size: 25 }] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const usage = await scanKvUsage(kv);
|
||||||
|
expect(usage.bytes).toBe(175);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setStorageSnapshot writes the snapshot fields", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
await setStorageSnapshot(kv, {
|
||||||
|
attachments_bytes: 1234,
|
||||||
|
attachments_count: 5,
|
||||||
|
kv_bytes_estimated: 678,
|
||||||
|
});
|
||||||
|
const counters = await getCounters(kv);
|
||||||
|
expect(counters.attachments_bytes).toBe(1234);
|
||||||
|
expect(counters.attachments_count).toBe(5);
|
||||||
|
expect(counters.kv_bytes_estimated).toBe(678);
|
||||||
|
expect(counters.storage_scanned_at).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { Counters, Env, StatsResponse } from "../types";
|
||||||
|
import { logger } from "../infrastructure/logger";
|
||||||
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
|
import { CountersRepository } from "../infrastructure/counters-repository";
|
||||||
|
import { WebSubSubscriptionRepository } from "../infrastructure/websub-subscription-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import { getAttachmentBucket } from "../infrastructure/attachments";
|
||||||
|
|
||||||
|
const EMPTY_COUNTERS: Counters = {
|
||||||
|
feeds_created: 0,
|
||||||
|
feeds_deleted: 0,
|
||||||
|
emails_received: 0,
|
||||||
|
emails_rejected: 0,
|
||||||
|
unsubscribes_sent: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCounters(kv: KVNamespace): Promise<Counters> {
|
||||||
|
try {
|
||||||
|
const stored = await new CountersRepository(kv).getRaw();
|
||||||
|
return { ...EMPTY_COUNTERS, ...(stored || {}) };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error reading counters", { error: String(error) });
|
||||||
|
return { ...EMPTY_COUNTERS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-modify-write the counters singleton. KV has no atomic increment, so
|
||||||
|
* concurrent invocations can lose updates — accepted given KV's eventual
|
||||||
|
* consistency and this app's low volume (see email-processor.ts storeEmail).
|
||||||
|
* Never throws: counter failures must not break ingestion or admin flows.
|
||||||
|
*/
|
||||||
|
export async function bumpCounters(
|
||||||
|
kv: KVNamespace,
|
||||||
|
changes: Partial<Omit<Counters, "first_seen">>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const current = await getCounters(kv);
|
||||||
|
|
||||||
|
current.feeds_created += changes.feeds_created ?? 0;
|
||||||
|
current.feeds_deleted += changes.feeds_deleted ?? 0;
|
||||||
|
current.emails_received += changes.emails_received ?? 0;
|
||||||
|
current.emails_rejected += changes.emails_rejected ?? 0;
|
||||||
|
current.unsubscribes_sent += changes.unsubscribes_sent ?? 0;
|
||||||
|
if (changes.last_email_at) current.last_email_at = changes.last_email_at;
|
||||||
|
if (changes.last_feed_created_at)
|
||||||
|
current.last_feed_created_at = changes.last_feed_created_at;
|
||||||
|
if (!current.first_seen) current.first_seen = new Date().toISOString();
|
||||||
|
|
||||||
|
await new CountersRepository(kv).put(current);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating counters", { error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countKeysByPrefix(
|
||||||
|
kv: KVNamespace,
|
||||||
|
prefix: string,
|
||||||
|
): Promise<number> {
|
||||||
|
return new FeedRepository(kv).countKeysByPrefix(prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStats(env: Env): Promise<StatsResponse> {
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
const [counters, feeds, websubCount] = await Promise.all([
|
||||||
|
getCounters(env.EMAIL_STORAGE),
|
||||||
|
repo.listFeeds(),
|
||||||
|
WebSubSubscriptionRepository.from(env).countKeys(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...counters,
|
||||||
|
active_feeds: feeds.length,
|
||||||
|
websub_subscriptions_active: websubCount,
|
||||||
|
attachments_enabled: !!getAttachmentBucket(env),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum the byte size and object count of every attachment stored in R2. */
|
||||||
|
export async function scanR2Usage(
|
||||||
|
bucket: R2Bucket,
|
||||||
|
): Promise<{ bytes: number; count: number }> {
|
||||||
|
let bytes = 0;
|
||||||
|
let count = 0;
|
||||||
|
let cursor: string | undefined;
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const listed = await bucket.list({ cursor });
|
||||||
|
for (const obj of listed.objects) {
|
||||||
|
bytes += obj.size;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
cursor = listed.truncated ? listed.cursor : undefined;
|
||||||
|
} while (cursor);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error scanning R2 usage", { error: String(error) });
|
||||||
|
}
|
||||||
|
return { bytes, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate KV storage used. KV exposes no size API, so we sum the per-email
|
||||||
|
* sizes already recorded in each feed's metadata — email bodies dominate KV
|
||||||
|
* usage. Feed config/websub/stats keys are excluded, so this is a lower-bound
|
||||||
|
* estimate.
|
||||||
|
*/
|
||||||
|
export async function scanKvUsage(kv: KVNamespace): Promise<{ bytes: number }> {
|
||||||
|
let bytes = 0;
|
||||||
|
try {
|
||||||
|
const repo = new FeedRepository(kv);
|
||||||
|
const feeds = await repo.listFeeds();
|
||||||
|
for (const feed of feeds) {
|
||||||
|
const metadata = await repo.getMetadata(FeedId.unchecked(feed.id));
|
||||||
|
if (!metadata) continue;
|
||||||
|
for (const email of metadata.emails) {
|
||||||
|
bytes += email.size ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error estimating KV usage", { error: String(error) });
|
||||||
|
}
|
||||||
|
return { bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overwrite the storage-usage snapshot fields on the counters singleton.
|
||||||
|
* Unlike bumpCounters these are set (not incremented). Never throws.
|
||||||
|
*/
|
||||||
|
export async function setStorageSnapshot(
|
||||||
|
kv: KVNamespace,
|
||||||
|
snapshot: {
|
||||||
|
attachments_bytes: number;
|
||||||
|
attachments_count: number;
|
||||||
|
kv_bytes_estimated: number;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const current = await getCounters(kv);
|
||||||
|
current.attachments_bytes = snapshot.attachments_bytes;
|
||||||
|
current.attachments_count = snapshot.attachments_count;
|
||||||
|
current.kv_bytes_estimated = snapshot.kv_bytes_estimated;
|
||||||
|
current.storage_scanned_at = new Date().toISOString();
|
||||||
|
if (!current.first_seen) current.first_seen = new Date().toISOString();
|
||||||
|
await new CountersRepository(kv).put(current);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error writing storage snapshot", { error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-3
@@ -1,6 +1,12 @@
|
|||||||
/** Maximum total size of emails stored per feed (bytes). */
|
/** Maximum total size of emails stored per feed (bytes). */
|
||||||
export const FEED_MAX_BYTES = 524288; // 512 KB
|
export const FEED_MAX_BYTES = 524288; // 512 KB
|
||||||
|
|
||||||
|
/** Cloudflare R2 free tier storage allowance (bytes). */
|
||||||
|
export const R2_FREE_TIER_BYTES = 10 * 1024 ** 3; // 10 GB
|
||||||
|
|
||||||
|
/** Cloudflare KV free tier storage allowance (bytes). */
|
||||||
|
export const KV_FREE_TIER_BYTES = 1 * 1024 ** 3; // 1 GB
|
||||||
|
|
||||||
/** Cache TTL for ForwardEmail.net IP list (milliseconds). */
|
/** Cache TTL for ForwardEmail.net IP list (milliseconds). */
|
||||||
export const FORWARD_EMAIL_IPS_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
export const FORWARD_EMAIL_IPS_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
@@ -10,9 +16,6 @@ export const ADMIN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 1 week
|
|||||||
/** Maximum number of feed items exposed in RSS/Atom responses. */
|
/** Maximum number of feed items exposed in RSS/Atom responses. */
|
||||||
export const MAX_FEED_ITEMS = 20;
|
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). */
|
/** Default WebSub lease duration (seconds). */
|
||||||
export const DEFAULT_LEASE_SECONDS = 86400; // 24 hours
|
export const DEFAULT_LEASE_SECONDS = 86400; // 24 hours
|
||||||
|
|
||||||
@@ -21,3 +24,18 @@ export const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days
|
|||||||
|
|
||||||
/** KV key for the global feed list. */
|
/** KV key for the global feed list. */
|
||||||
export const FEEDS_LIST_KEY = "feeds:list";
|
export const FEEDS_LIST_KEY = "feeds:list";
|
||||||
|
|
||||||
|
/** KV key for the monitoring counters singleton. */
|
||||||
|
export const STATS_KEY = "stats:counters";
|
||||||
|
|
||||||
|
/** Default TTL for a cached per-domain favicon (seconds). */
|
||||||
|
export const ICON_TTL_SECONDS = 7 * 24 * 60 * 60; // 1 week
|
||||||
|
|
||||||
|
/** Maximum accepted favicon size (bytes); larger responses are rejected. */
|
||||||
|
export const MAX_ICON_BYTES = 100 * 1024; // 100 KB
|
||||||
|
|
||||||
|
/** Timeout for an outbound favicon fetch (milliseconds). */
|
||||||
|
export const ICON_FETCH_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
/** Timeout for an outbound RFC 8058 one-click unsubscribe request (milliseconds). */
|
||||||
|
export const UNSUBSCRIBE_TIMEOUT_MS = 5000;
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* A source of "now", injected into the domain so aggregates never reach for
|
||||||
|
* ambient `Date.now()`. Production wires `systemClock`; tests can supply a fixed
|
||||||
|
* clock for deterministic expiry/timestamp assertions.
|
||||||
|
*/
|
||||||
|
export interface Clock {
|
||||||
|
now(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const systemClock: Clock = {
|
||||||
|
now: () => Date.now(),
|
||||||
|
};
|
||||||
@@ -3,15 +3,15 @@ import { EmailParser } from "./email-parser";
|
|||||||
|
|
||||||
describe("EmailParser.extractFeedId", () => {
|
describe("EmailParser.extractFeedId", () => {
|
||||||
it("extracts a valid feed ID from an email address", () => {
|
it("extracts a valid feed ID from an email address", () => {
|
||||||
expect(EmailParser.extractFeedId("river.castle.42@example.com")).toBe(
|
expect(
|
||||||
"river.castle.42",
|
EmailParser.extractFeedId("river.castle.42@example.com")?.value,
|
||||||
);
|
).toBe("river.castle.42");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is case-insensitive for the local part", () => {
|
it("is case-insensitive for the local part", () => {
|
||||||
expect(EmailParser.extractFeedId("River.Castle.42@example.com")).toBe(
|
expect(
|
||||||
"River.Castle.42",
|
EmailParser.extractFeedId("River.Castle.42@example.com")?.value,
|
||||||
);
|
).toBe("River.Castle.42");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for an address with no feed ID format", () => {
|
it("returns null for an address with no feed ID format", () => {
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { EmailData } from "../types";
|
import { EmailData } from "../types";
|
||||||
|
import { FeedId } from "./value-objects/feed-id";
|
||||||
|
|
||||||
export class EmailParser {
|
export class EmailParser {
|
||||||
// Matches noun1.noun2.XY (the feed ID format) before the @ symbol
|
/**
|
||||||
static extractFeedId(emailAddress: string): string | null {
|
* Extract the feed id from an inbound recipient address. Returns a validated
|
||||||
const match = emailAddress.match(/^([a-z]+\.[a-z]+\.\d{2})@/i);
|
* `FeedId` value object (not a raw string) so the most untrusted input in the
|
||||||
return match ? match[1] : null;
|
* system — an address typed by a sender — is guarded at the parse boundary and
|
||||||
|
* never needs `FeedId.unchecked` downstream.
|
||||||
|
*/
|
||||||
|
static extractFeedId(emailAddress: string): FeedId | null {
|
||||||
|
return FeedId.parse(emailAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { FeedId } from "./value-objects/feed-id";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain events the Feed aggregate records when it mutates. They describe *what
|
||||||
|
* happened* in business terms and carry their own `feedId`, so the application
|
||||||
|
* dispatcher can route side effects (counters, WebSub pings, favicon caching)
|
||||||
|
* without the caller threading the id back in. This keeps the aggregate ignorant
|
||||||
|
* of infrastructure and the orchestration code free of scattered, inline effects.
|
||||||
|
*
|
||||||
|
* Only mutations that currently have side effects emit events — feed creation
|
||||||
|
* and email ingestion. Edits and removals carry no side effect, so they emit
|
||||||
|
* nothing. Side effects that don't flow through the aggregate (a rejected email,
|
||||||
|
* a feed deletion that bypasses the aggregate, bulk admin operations) stay
|
||||||
|
* outside this mechanism by design — they have no aggregate event to ride on.
|
||||||
|
*/
|
||||||
|
export type FeedEvent =
|
||||||
|
| { type: "FeedCreated"; feedId: FeedId }
|
||||||
|
| { type: "EmailIngested"; feedId: FeedId; iconDomain?: string };
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { FEEDS_LIST_KEY, STATS_KEY } from "../config/constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The KV key schema, in one pure place. Every repository builds its keys here so
|
||||||
|
* the wire format lives in a single module — never inline a `feed:`/`icon:`/
|
||||||
|
* `websub:` string elsewhere. Strings are byte-identical to the original schema;
|
||||||
|
* changing them would require migrating live KV data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WEBSUB_PREFIX = "websub:subs:";
|
||||||
|
|
||||||
|
export const feedKeys = {
|
||||||
|
config: (feedId: string): string => `feed:${feedId}:config`,
|
||||||
|
metadata: (feedId: string): string => `feed:${feedId}:metadata`,
|
||||||
|
|
||||||
|
/** Prefix covering every key owned by a feed (config, metadata, emails). */
|
||||||
|
feedPrefix: (feedId: string): string => `feed:${feedId}:`,
|
||||||
|
|
||||||
|
/** Mint a fresh, time-ordered email key. Call once and reuse the result. */
|
||||||
|
newEmail: (feedId: string): string => `feed:${feedId}:${Date.now()}`,
|
||||||
|
|
||||||
|
/** KV key for a domain's cached favicon (shared across feeds). */
|
||||||
|
icon: (domain: string): string => `icon:${domain}`,
|
||||||
|
|
||||||
|
websub: (feedId: string): string => `${WEBSUB_PREFIX}${feedId}`,
|
||||||
|
|
||||||
|
/** Prefix matching every per-feed WebSub subscription key. */
|
||||||
|
websubPrefix: (): string => WEBSUB_PREFIX,
|
||||||
|
|
||||||
|
/** True when `key` is an email entry (not the feed's config/metadata key). */
|
||||||
|
isEmail: (feedId: string, key: string): boolean => {
|
||||||
|
const suffix = key.slice(feedKeys.feedPrefix(feedId).length);
|
||||||
|
return suffix !== "config" && suffix !== "metadata";
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Recover the feed id embedded in an email key (`feed:<id>:<ts>`). */
|
||||||
|
feedIdFromEmail: (key: string): string => key.split(":")[1],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export { FEEDS_LIST_KEY, STATS_KEY };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* The Feed aggregate's internal config state, in domain (camelCase) vocabulary.
|
||||||
|
* This is deliberately NOT the persistence shape: the snake_case `FeedConfig`
|
||||||
|
* DTO is an infrastructure concern, and the translation between the two lives in
|
||||||
|
* `infrastructure/feed-mapper.ts`. The domain never speaks the storage dialect.
|
||||||
|
*
|
||||||
|
* `expiresAt` is an absolute instant (epoch ms) already resolved from a
|
||||||
|
* `Lifetime`; the aggregate stores the resolved value, not the policy.
|
||||||
|
*/
|
||||||
|
export interface FeedState {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
language: string;
|
||||||
|
author?: string;
|
||||||
|
allowedSenders: string[];
|
||||||
|
blockedSenders: string[];
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
expiresAt?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createMockEnv } from "../test/setup";
|
||||||
|
import { Feed, CreateFeedInput } from "./feed.aggregate";
|
||||||
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
|
import { FeedId } from "./value-objects/feed-id";
|
||||||
|
import { Lifetime } from "./value-objects/lifetime";
|
||||||
|
import { FeedState } from "./feed-state";
|
||||||
|
import { Clock } from "./clock";
|
||||||
|
import type { Env, EmailMetadata } from "../types";
|
||||||
|
|
||||||
|
const FID = FeedId.unchecked("a.b.42");
|
||||||
|
|
||||||
|
const mockEnv = () => createMockEnv() as unknown as Env;
|
||||||
|
|
||||||
|
const fixedClock = (now: number): Clock => ({ now: () => now });
|
||||||
|
|
||||||
|
const createInput = (
|
||||||
|
overrides: Partial<CreateFeedInput> = {},
|
||||||
|
): CreateFeedInput => ({
|
||||||
|
title: "News",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = (overrides: Partial<FeedState> = {}): FeedState => ({
|
||||||
|
title: "T",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
createdAt: 0,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry = (overrides: Partial<EmailMetadata> = {}): EmailMetadata => ({
|
||||||
|
key: "feed:a.b.42:1",
|
||||||
|
subject: "Hello",
|
||||||
|
receivedAt: 1,
|
||||||
|
size: 10,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Feed.create", () => {
|
||||||
|
it("builds a config with an empty email index and no expiry by default", () => {
|
||||||
|
const feed = Feed.create(FID, createInput());
|
||||||
|
expect(feed.id.value).toBe("a.b.42");
|
||||||
|
expect(feed.title).toBe("News");
|
||||||
|
expect(feed.expiresAt).toBeUndefined();
|
||||||
|
expect(feed.emails).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves expiry from the supplied lifetime using the injected clock", () => {
|
||||||
|
const NOW = 1_000_000;
|
||||||
|
const feed = Feed.create(FID, createInput(), {
|
||||||
|
clock: fixedClock(NOW),
|
||||||
|
lifetime: Lifetime.ofHours(2),
|
||||||
|
});
|
||||||
|
expect(feed.createdAt).toBe(NOW);
|
||||||
|
expect(feed.updatedAt).toBe(NOW);
|
||||||
|
expect(feed.expiresAt).toBe(NOW + 2 * 3_600_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trusts only deps.lifetime, not the client lifetimeHours field", () => {
|
||||||
|
// The aggregate no longer parses lifetime policy: the application resolves
|
||||||
|
// the effective Lifetime (env override etc.) and hands it in.
|
||||||
|
const feed = Feed.create(FID, createInput({ lifetimeHours: 9999 }));
|
||||||
|
expect(feed.expiresAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a non-positive lifetime as no expiry", () => {
|
||||||
|
expect(
|
||||||
|
Feed.create(FID, createInput(), { lifetime: Lifetime.ofHours(0) })
|
||||||
|
.expiresAt,
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
Feed.create(FID, createInput(), { lifetime: Lifetime.ofHours(-5) })
|
||||||
|
.expiresAt,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Feed.isExpired / accepts", () => {
|
||||||
|
it("reports expiry against the configured instant", () => {
|
||||||
|
const feed = Feed.reconstitute(FID, state({ expiresAt: 100 }), {
|
||||||
|
emails: [],
|
||||||
|
});
|
||||||
|
expect(feed.isExpired(50)).toBe(false);
|
||||||
|
expect(feed.isExpired(150)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the injected clock when no instant is supplied", () => {
|
||||||
|
const feed = Feed.reconstitute(
|
||||||
|
FID,
|
||||||
|
state({ expiresAt: 100 }),
|
||||||
|
{ emails: [] },
|
||||||
|
fixedClock(150),
|
||||||
|
);
|
||||||
|
expect(feed.isExpired()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the sender policy", () => {
|
||||||
|
const feed = Feed.reconstitute(
|
||||||
|
FID,
|
||||||
|
state({ allowedSenders: ["good@example.com"] }),
|
||||||
|
{ emails: [] },
|
||||||
|
);
|
||||||
|
expect(feed.accepts(["good@example.com"])).toBe("accepted");
|
||||||
|
expect(feed.accepts(["bad@example.com"])).toBe("blocked");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Feed.edit", () => {
|
||||||
|
it("recomputes expiry only when a lifetime is supplied", () => {
|
||||||
|
const NOW = 5_000_000;
|
||||||
|
const FUTURE = NOW + 10 * 3_600_000;
|
||||||
|
const feed = Feed.reconstitute(
|
||||||
|
FID,
|
||||||
|
state({ expiresAt: FUTURE }),
|
||||||
|
{ emails: [] },
|
||||||
|
fixedClock(NOW),
|
||||||
|
);
|
||||||
|
|
||||||
|
feed.edit({ title: "T2" }); // no lifetime ⇒ expiry preserved
|
||||||
|
expect(feed.expiresAt).toBe(FUTURE);
|
||||||
|
expect(feed.updatedAt).toBe(NOW);
|
||||||
|
|
||||||
|
feed.edit({ title: "T3" }, { lifetime: Lifetime.ofHours(1) });
|
||||||
|
expect(feed.expiresAt).toBe(NOW + 3_600_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to edit an already-expired feed", () => {
|
||||||
|
const feed = Feed.reconstitute(
|
||||||
|
FID,
|
||||||
|
state({ expiresAt: 100 }),
|
||||||
|
{ emails: [] },
|
||||||
|
fixedClock(200),
|
||||||
|
);
|
||||||
|
expect(feed.edit({ title: "X" }).status).toBe("expired");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Feed.ingest", () => {
|
||||||
|
it("prepends the entry, tracks icon/unsub and trims to the byte budget", () => {
|
||||||
|
const feed = Feed.reconstitute(FID, state(), {
|
||||||
|
emails: [entry({ key: "old", size: 400 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { dropped } = feed.ingest(entry({ key: "new", size: 400 }), {
|
||||||
|
maxBytes: 500,
|
||||||
|
iconDomain: "example.com",
|
||||||
|
unsub: { senderKey: "news@example.com", url: "https://u/1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(feed.emails[0].key).toBe("new");
|
||||||
|
expect(feed.iconDomain).toBe("example.com");
|
||||||
|
expect(feed.unsubscribeUrls()).toEqual({
|
||||||
|
"news@example.com": "https://u/1",
|
||||||
|
});
|
||||||
|
expect(dropped.map((e) => e.key)).toEqual(["old"]);
|
||||||
|
expect(feed.emails.map((e) => e.key)).toEqual(["new"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always keeps the just-ingested entry, even when it alone is oversized", () => {
|
||||||
|
const feed = Feed.reconstitute(FID, state(), { emails: [] });
|
||||||
|
|
||||||
|
const { dropped } = feed.ingest(entry({ key: "huge", size: 999 }), {
|
||||||
|
maxBytes: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(dropped).toEqual([]);
|
||||||
|
expect(feed.emails.map((e) => e.key)).toEqual(["huge"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Feed.removeEmails", () => {
|
||||||
|
it("drops matching keys and returns the removed entries", () => {
|
||||||
|
const feed = Feed.reconstitute(FID, state(), {
|
||||||
|
emails: [
|
||||||
|
entry({ key: "k1" }),
|
||||||
|
entry({ key: "k2" }),
|
||||||
|
entry({ key: "k3" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { removed } = feed.removeEmails(["k1", "k3", "missing"]);
|
||||||
|
expect(removed.map((e) => e.key).sort()).toEqual(["k1", "k3"]);
|
||||||
|
expect(feed.emails.map((e) => e.key)).toEqual(["k2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Feed events", () => {
|
||||||
|
it("records FeedCreated on create and drains it once", () => {
|
||||||
|
const feed = Feed.create(FID, createInput());
|
||||||
|
expect(feed.pullEvents()).toEqual([{ type: "FeedCreated", feedId: FID }]);
|
||||||
|
// Draining clears: a second pull is empty.
|
||||||
|
expect(feed.pullEvents()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records EmailIngested (with icon domain) on ingest", () => {
|
||||||
|
const feed = Feed.reconstitute(FID, state(), { emails: [] });
|
||||||
|
feed.ingest(entry({ key: "k" }), {
|
||||||
|
maxBytes: 1_000_000,
|
||||||
|
iconDomain: "example.com",
|
||||||
|
});
|
||||||
|
expect(feed.pullEvents()).toEqual([
|
||||||
|
{ type: "EmailIngested", feedId: FID, iconDomain: "example.com" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits no events for edit / removeEmails", () => {
|
||||||
|
const feed = Feed.reconstitute(
|
||||||
|
FID,
|
||||||
|
state({ expiresAt: 9_999_999_999 }),
|
||||||
|
{ emails: [entry({ key: "k1" })] },
|
||||||
|
fixedClock(1000),
|
||||||
|
);
|
||||||
|
feed.edit({ title: "X" });
|
||||||
|
feed.edit({ description: "Y" });
|
||||||
|
feed.removeEmails(["k1"]);
|
||||||
|
expect(feed.pullEvents()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FeedRepository.load / save round-trip", () => {
|
||||||
|
it("persists a created feed and reflects later mutations", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
const created = Feed.create(FID, createInput({ title: "Round" }));
|
||||||
|
await repo.save(created);
|
||||||
|
|
||||||
|
const loaded = await repo.load(FID);
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.title).toBe("Round");
|
||||||
|
|
||||||
|
loaded!.ingest(entry({ key: "feed:a.b.42:1" }), { maxBytes: 1_000_000 });
|
||||||
|
await repo.saveMetadata(loaded!);
|
||||||
|
|
||||||
|
const reloaded = await repo.load(FID);
|
||||||
|
expect(reloaded!.emails.map((e) => e.key)).toEqual(["feed:a.b.42:1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the feed has no config", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
expect(await repo.load(FeedId.unchecked("missing"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { FeedMetadata, EmailMetadata } from "../types";
|
||||||
|
import { FeedState } from "./feed-state";
|
||||||
|
import { FeedId } from "./value-objects/feed-id";
|
||||||
|
import { Lifetime } from "./value-objects/lifetime";
|
||||||
|
import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
|
||||||
|
import { Clock, systemClock } from "./clock";
|
||||||
|
import { FeedEvent } from "./events";
|
||||||
|
|
||||||
|
export interface CreateFeedInput {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
language: string;
|
||||||
|
allowedSenders: string[];
|
||||||
|
blockedSenders: string[];
|
||||||
|
/** Raw client-requested lifetime; the application resolves it into a `Lifetime`. */
|
||||||
|
lifetimeHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFeedInput {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
language?: string;
|
||||||
|
allowedSenders?: string[];
|
||||||
|
blockedSenders?: string[];
|
||||||
|
lifetimeHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependencies the aggregate needs from the outside but must not reach for
|
||||||
|
* itself: a clock (never ambient `Date.now()`) and an already-resolved
|
||||||
|
* `Lifetime`. The application layer decides the lifetime — parsing env config and
|
||||||
|
* applying any server-side `FEED_TTL_HOURS` override — and hands the VO in.
|
||||||
|
*/
|
||||||
|
export interface CreateFeedDeps {
|
||||||
|
clock?: Clock;
|
||||||
|
/** Effective lifetime, already resolved by the application. */
|
||||||
|
lifetime?: Lifetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditFeedDeps {
|
||||||
|
/**
|
||||||
|
* Effective lifetime, already resolved by the application. Its *presence* means
|
||||||
|
* "recompute expiry"; its absence preserves the current expiry — which covers
|
||||||
|
* the dashboard's title/description quick-edit.
|
||||||
|
*/
|
||||||
|
lifetime?: Lifetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngestOptions {
|
||||||
|
maxBytes: number;
|
||||||
|
iconDomain?: string;
|
||||||
|
/** RFC 8058 one-click unsubscribe link, keyed by the sending newsletter. */
|
||||||
|
unsub?: { senderKey: string; url: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Feed aggregate: the consistency boundary around a feed's config and the
|
||||||
|
* metadata index of its emails. All mutations to either go through a method
|
||||||
|
* here so the invariants (expiry policy, sender policy, byte budget) live in one
|
||||||
|
* place. Email bodies are large blobs referenced by `metadata.emails[].key` and
|
||||||
|
* deliberately sit *outside* the aggregate — the caller flushes them alongside
|
||||||
|
* `FeedRepository.save`/`saveMetadata`.
|
||||||
|
*
|
||||||
|
* Its config is held as domain `FeedState` (camelCase), never the snake_case
|
||||||
|
* persistence DTO — `FeedRepository` translates via `feed-mapper.ts`. I/O-free
|
||||||
|
* and time-free: load and persist through the repository; time comes from an
|
||||||
|
* injected `Clock`. KV has no multi-key transaction, so a future Durable Object
|
||||||
|
* keyed by feed id would wrap load→mutate→save to serialise concurrent writers
|
||||||
|
* (see email-processor.ts).
|
||||||
|
*/
|
||||||
|
export class Feed {
|
||||||
|
private readonly _events: FeedEvent[] = [];
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
readonly id: FeedId,
|
||||||
|
private _state: FeedState,
|
||||||
|
private _metadata: FeedMetadata,
|
||||||
|
private readonly clock: Clock,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Mint a brand-new feed with an empty email index. */
|
||||||
|
static create(
|
||||||
|
id: FeedId,
|
||||||
|
input: CreateFeedInput,
|
||||||
|
deps: CreateFeedDeps = {},
|
||||||
|
): Feed {
|
||||||
|
const clock = deps.clock ?? systemClock;
|
||||||
|
const now = clock.now();
|
||||||
|
const expiresAt = (deps.lifetime ?? Lifetime.never).resolveExpiry(now);
|
||||||
|
const state: FeedState = {
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
language: input.language,
|
||||||
|
allowedSenders: input.allowedSenders,
|
||||||
|
blockedSenders: input.blockedSenders,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
const feed = new Feed(id, state, { emails: [] }, clock);
|
||||||
|
feed._events.push({ type: "FeedCreated", feedId: id });
|
||||||
|
return feed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild an aggregate from persisted (already-mapped) domain state. */
|
||||||
|
static reconstitute(
|
||||||
|
id: FeedId,
|
||||||
|
state: FeedState,
|
||||||
|
metadata: FeedMetadata,
|
||||||
|
clock: Clock = systemClock,
|
||||||
|
): Feed {
|
||||||
|
return new Feed(id, state, metadata, clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Intention-revealing reads ─────────────────────────────────────────────
|
||||||
|
// The aggregate exposes named fields and copies of its collections, never the
|
||||||
|
// raw `state`/`metadata` objects — a shallow `Readonly<…>` would still let a
|
||||||
|
// caller mutate the arrays inside. Persistence reads `state()` /
|
||||||
|
// `toMetadataSnapshot()`; the mapper derives the DTOs.
|
||||||
|
|
||||||
|
get title(): string {
|
||||||
|
return this._state.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
get description(): string | undefined {
|
||||||
|
return this._state.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
get language(): string {
|
||||||
|
return this._state.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
get createdAt(): number {
|
||||||
|
return this._state.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get updatedAt(): number | undefined {
|
||||||
|
return this._state.updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get expiresAt(): number | undefined {
|
||||||
|
return this._state.expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
get iconDomain(): string | undefined {
|
||||||
|
return this._metadata.iconDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedSenders(): string[] {
|
||||||
|
return [...this._state.allowedSenders];
|
||||||
|
}
|
||||||
|
|
||||||
|
blockedSenders(): string[] {
|
||||||
|
return [...this._state.blockedSenders];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A copy of the email index — mutating it never touches aggregate state. */
|
||||||
|
get emails(): readonly EmailMetadata[] {
|
||||||
|
return [...this._metadata.emails];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-sender one-click unsubscribe links (copy). */
|
||||||
|
unsubscribeUrls(): Record<string, string> {
|
||||||
|
return { ...(this._metadata.unsubscribe ?? {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistence snapshots (repository-only) ───────────────────────────────
|
||||||
|
|
||||||
|
/** A copy of the domain config state for the repository to map + persist. */
|
||||||
|
state(): FeedState {
|
||||||
|
return {
|
||||||
|
...this._state,
|
||||||
|
allowedSenders: [...this._state.allowedSenders],
|
||||||
|
blockedSenders: [...this._state.blockedSenders],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A serialisable copy of the email index for the repository to persist. */
|
||||||
|
toMetadataSnapshot(): FeedMetadata {
|
||||||
|
return { ...this._metadata, emails: [...this._metadata.emails] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain the domain events recorded since the last pull. The application layer
|
||||||
|
* calls this after persisting and feeds them to a dispatcher that runs the
|
||||||
|
* side effects (counters, WebSub, favicon). Clearing on read keeps a long-lived
|
||||||
|
* aggregate from re-emitting.
|
||||||
|
*/
|
||||||
|
pullEvents(): FeedEvent[] {
|
||||||
|
return this._events.splice(0, this._events.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
isExpired(now: number = this.clock.now()): boolean {
|
||||||
|
// The shared `isExpired` predicate (domain/feed.ts) lives on the read path
|
||||||
|
// and speaks the persistence DTO; the aggregate checks its own domain state.
|
||||||
|
return this._state.expiresAt !== undefined && this._state.expiresAt <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
accepts(senders: string[]): SenderDecision {
|
||||||
|
return SenderPolicy.fromLists(
|
||||||
|
this._state.allowedSenders,
|
||||||
|
this._state.blockedSenders,
|
||||||
|
).decide(senders);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an email to the front of the index, refresh the icon domain and the
|
||||||
|
* per-sender unsubscribe link, then trim the oldest entries back under the
|
||||||
|
* byte budget. Returns the dropped entries so the caller can purge their
|
||||||
|
* bodies/attachments.
|
||||||
|
*/
|
||||||
|
ingest(
|
||||||
|
entry: EmailMetadata,
|
||||||
|
opts: IngestOptions,
|
||||||
|
): { dropped: EmailMetadata[] } {
|
||||||
|
this._metadata.emails.unshift(entry);
|
||||||
|
|
||||||
|
if (opts.iconDomain) {
|
||||||
|
this._metadata.iconDomain = opts.iconDomain;
|
||||||
|
}
|
||||||
|
if (opts.unsub) {
|
||||||
|
this._metadata.unsubscribe = {
|
||||||
|
...(this._metadata.unsubscribe ?? {}),
|
||||||
|
[opts.unsub.senderKey]: opts.unsub.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this._events.push({
|
||||||
|
type: "EmailIngested",
|
||||||
|
feedId: this.id,
|
||||||
|
iconDomain: opts.iconDomain,
|
||||||
|
});
|
||||||
|
return this.trimToByteBudget(opts.maxBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce the per-feed byte budget by dropping the oldest emails (from the
|
||||||
|
* tail of the index) until the total fits, always keeping at least one entry.
|
||||||
|
* Returns the dropped entries so the caller can purge their KV/R2 storage.
|
||||||
|
*/
|
||||||
|
private trimToByteBudget(maxBytes: number): { dropped: EmailMetadata[] } {
|
||||||
|
const emails = this._metadata.emails;
|
||||||
|
let totalSize = emails.reduce((sum, e) => sum + (e.size ?? 0), 0);
|
||||||
|
const dropped: EmailMetadata[] = [];
|
||||||
|
while (totalSize > maxBytes && emails.length > 1) {
|
||||||
|
const entry = emails.pop()!;
|
||||||
|
totalSize -= entry.size ?? 0;
|
||||||
|
dropped.push(entry);
|
||||||
|
}
|
||||||
|
return { dropped };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the given email keys from the index. Returns the removed entries so the
|
||||||
|
* caller can purge their bodies/attachments.
|
||||||
|
*/
|
||||||
|
removeEmails(keys: string[]): { removed: EmailMetadata[] } {
|
||||||
|
const target = new Set(keys);
|
||||||
|
const removed: EmailMetadata[] = [];
|
||||||
|
const kept: EmailMetadata[] = [];
|
||||||
|
for (const entry of this._metadata.emails) {
|
||||||
|
(target.has(entry.key) ? removed : kept).push(entry);
|
||||||
|
}
|
||||||
|
this._metadata.emails = kept;
|
||||||
|
return { removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single edit path. Apply the patch (only the fields it carries) and
|
||||||
|
* recompute expiry when the application supplies a `Lifetime` — an absent
|
||||||
|
* lifetime preserves the current expiry, which covers the dashboard's
|
||||||
|
* title/description quick-edit. Rejects an already-expired feed without
|
||||||
|
* mutating it, so a quick-edit can no more touch an expired feed than a full
|
||||||
|
* edit can.
|
||||||
|
*/
|
||||||
|
edit(
|
||||||
|
patch: UpdateFeedInput,
|
||||||
|
deps: EditFeedDeps = {},
|
||||||
|
): { status: "ok" | "expired" } {
|
||||||
|
if (this.isExpired()) return { status: "expired" };
|
||||||
|
|
||||||
|
const now = this.clock.now();
|
||||||
|
const expiresAt = deps.lifetime
|
||||||
|
? deps.lifetime.resolveExpiry(now)
|
||||||
|
: this._state.expiresAt;
|
||||||
|
|
||||||
|
if (patch.title !== undefined) this._state.title = patch.title;
|
||||||
|
if (patch.description !== undefined) {
|
||||||
|
this._state.description = patch.description;
|
||||||
|
}
|
||||||
|
if (patch.language !== undefined) this._state.language = patch.language;
|
||||||
|
if (patch.allowedSenders !== undefined) {
|
||||||
|
this._state.allowedSenders = patch.allowedSenders;
|
||||||
|
}
|
||||||
|
if (patch.blockedSenders !== undefined) {
|
||||||
|
this._state.blockedSenders = patch.blockedSenders;
|
||||||
|
}
|
||||||
|
this._state.updatedAt = now;
|
||||||
|
this._state.expiresAt = expiresAt;
|
||||||
|
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isExpired } from "./feed";
|
||||||
|
|
||||||
|
describe("isExpired", () => {
|
||||||
|
it("is false when no expiry is set", () => {
|
||||||
|
expect(isExpired({ expires_at: undefined }, 1000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true at or past the expiry instant", () => {
|
||||||
|
expect(isExpired({ expires_at: 1000 }, 1000)).toBe(true);
|
||||||
|
expect(isExpired({ expires_at: 1000 }, 1001)).toBe(true);
|
||||||
|
expect(isExpired({ expires_at: 1000 }, 999)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { FeedConfig } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expiry predicate, shared between the Feed aggregate and the read-model
|
||||||
|
* routes (rss/atom/entries) that render from a config snapshot without loading
|
||||||
|
* the aggregate. This is the *only* feed invariant that lives outside the
|
||||||
|
* aggregate, precisely because the hot read path bypasses it.
|
||||||
|
*
|
||||||
|
* `now` defaults to the wall clock for convenience at the HTTP edge; the
|
||||||
|
* aggregate always passes its injected clock so its own behaviour stays
|
||||||
|
* deterministic.
|
||||||
|
*/
|
||||||
|
export function isExpired(
|
||||||
|
config: Pick<FeedConfig, "expires_at">,
|
||||||
|
now: number = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
return config.expires_at !== undefined && config.expires_at <= now;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** Human-readable byte size (B / KB / MB / GB). */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024)
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Domain } from "./domain";
|
||||||
|
|
||||||
|
describe("Domain", () => {
|
||||||
|
it("normalises case and whitespace", () => {
|
||||||
|
expect(Domain.parse(" Example.COM ")?.value).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips a leading @ and trailing dots", () => {
|
||||||
|
expect(Domain.parse("@example.com")?.value).toBe("example.com");
|
||||||
|
expect(Domain.parse("example.com.")?.value).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty input", () => {
|
||||||
|
expect(Domain.parse("")).toBeNull();
|
||||||
|
expect(Domain.parse("@")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares by normalised value", () => {
|
||||||
|
expect(
|
||||||
|
Domain.parse("Example.com")!.matches(Domain.parse("example.com")!),
|
||||||
|
).toBe(true);
|
||||||
|
expect(Domain.parse("a.com")!.matches(Domain.parse("b.com")!)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* A normalised DNS domain (lowercased, no leading `@`, no trailing dots).
|
||||||
|
* Accepts both bare (`example.com`) and allowlist-style (`@example.com`) input.
|
||||||
|
*/
|
||||||
|
export class Domain {
|
||||||
|
private constructor(readonly value: string) {}
|
||||||
|
|
||||||
|
static parse(raw: string): Domain | null {
|
||||||
|
const normalized = raw
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^@+/, "")
|
||||||
|
.replace(/\.+$/, "");
|
||||||
|
return normalized ? new Domain(normalized) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches(other: Domain): boolean {
|
||||||
|
return this.value === other.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { EmailAddress } from "./email-address";
|
||||||
|
|
||||||
|
describe("EmailAddress", () => {
|
||||||
|
it("parses a bare address and normalises it", () => {
|
||||||
|
const email = EmailAddress.parse("News@Example.COM")!;
|
||||||
|
expect(email.normalized).toBe("news@example.com");
|
||||||
|
expect(email.domain.value).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a display form (Name <addr>)", () => {
|
||||||
|
const email = EmailAddress.parse("GitHub <news@GitHub.com>")!;
|
||||||
|
expect(email.normalized).toBe("news@github.com");
|
||||||
|
expect(email.domain.value).toBe("github.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips a trailing dot from the domain", () => {
|
||||||
|
expect(EmailAddress.parse("a@Example.COM.")?.domain.value).toBe(
|
||||||
|
"example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when there is no address", () => {
|
||||||
|
expect(EmailAddress.parse("not an email")).toBeNull();
|
||||||
|
expect(EmailAddress.parse("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Domain } from "./domain";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A normalised email address. `parse` accepts a bare address (`a@b.com`) or a
|
||||||
|
* display form (`Name <a@b.com>`), lowercasing the local part and normalising
|
||||||
|
* the domain. Returns null when no plausible address can be found.
|
||||||
|
*/
|
||||||
|
export class EmailAddress {
|
||||||
|
private constructor(
|
||||||
|
readonly normalized: string,
|
||||||
|
readonly domain: Domain,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static parse(raw: string): EmailAddress | null {
|
||||||
|
const match = raw.match(/([^\s<>@]+)@([^\s<>@]+)/);
|
||||||
|
if (!match) return null;
|
||||||
|
const domain = Domain.parse(match[2]);
|
||||||
|
if (!domain) return null;
|
||||||
|
const local = match[1].trim().toLowerCase();
|
||||||
|
return new EmailAddress(`${local}@${domain.value}`, domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { FeedId } from "./feed-id";
|
||||||
|
|
||||||
|
describe("FeedId.parse", () => {
|
||||||
|
it("extracts the feed id from an inbound address", () => {
|
||||||
|
expect(FeedId.parse("river.castle.42@example.com")?.value).toBe(
|
||||||
|
"river.castle.42",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the original casing of the local part", () => {
|
||||||
|
expect(FeedId.parse("River.Castle.42@example.com")?.value).toBe(
|
||||||
|
"River.Castle.42",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed feed ids", () => {
|
||||||
|
expect(FeedId.parse("user@example.com")).toBeNull();
|
||||||
|
expect(FeedId.parse("notanemail")).toBeNull();
|
||||||
|
expect(FeedId.parse("river.castle.4@example.com")).toBeNull();
|
||||||
|
expect(FeedId.parse("river.castle.123@example.com")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FeedId.generate", () => {
|
||||||
|
it("produces the noun.noun.NN format", () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
expect(FeedId.generate().value).toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through parse from an address", () => {
|
||||||
|
const id = FeedId.generate();
|
||||||
|
expect(FeedId.parse(`${id.value}@example.com`)?.value).toBe(id.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { nouns } from "../../data/nouns";
|
||||||
|
|
||||||
|
// Feed IDs are noun1.noun2.XY (two lowercase nouns + a 2-digit suffix).
|
||||||
|
const FEED_ID_IN_ADDRESS = /^([a-z]+\.[a-z]+\.\d{2})@/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A feed identifier. `parse` pulls it from the local part of an inbound email
|
||||||
|
* address; `generate` mints a fresh one. The original casing is preserved.
|
||||||
|
*/
|
||||||
|
export class FeedId {
|
||||||
|
private constructor(readonly value: string) {}
|
||||||
|
|
||||||
|
/** Extract the feed id from an inbound address (`noun.noun.NN@domain`). */
|
||||||
|
static parse(emailAddress: string): FeedId | null {
|
||||||
|
const match = emailAddress.match(FEED_ID_IN_ADDRESS);
|
||||||
|
return match ? new FeedId(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a string as a FeedId WITHOUT revalidating it. The caller asserts the id
|
||||||
|
* originated from our own minting — a route param echoing a stored id, a
|
||||||
|
* `feeds:list` entry, or an email/KV key. The name is deliberately blunt: a
|
||||||
|
* wrong id is not rejected here, it simply misses in KV and 404s downstream.
|
||||||
|
* Untrusted external input (an inbound address) must go through `parse` instead.
|
||||||
|
*/
|
||||||
|
static unchecked(value: string): FeedId {
|
||||||
|
return new FeedId(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static generate(): FeedId {
|
||||||
|
const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
|
||||||
|
const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
|
||||||
|
const number = Math.floor(Math.random() * 90) + 10;
|
||||||
|
return new FeedId(`${noun1}.${noun2}.${number}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Lifetime } from "./lifetime";
|
||||||
|
|
||||||
|
const NOW = 1_000_000;
|
||||||
|
const HOUR = 3_600_000;
|
||||||
|
|
||||||
|
describe("Lifetime", () => {
|
||||||
|
it("resolves a positive lifetime to an absolute expiry", () => {
|
||||||
|
expect(Lifetime.ofHours(2).resolveExpiry(NOW)).toBe(NOW + 2 * HOUR);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never expires for Lifetime.never", () => {
|
||||||
|
expect(Lifetime.never.resolveExpiry(NOW)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats non-positive or non-finite hours as no expiry", () => {
|
||||||
|
expect(Lifetime.ofHours(0).resolveExpiry(NOW)).toBeUndefined();
|
||||||
|
expect(Lifetime.ofHours(-5).resolveExpiry(NOW)).toBeUndefined();
|
||||||
|
expect(Lifetime.ofHours(NaN).resolveExpiry(NOW)).toBeUndefined();
|
||||||
|
expect(Lifetime.ofHours(Infinity).resolveExpiry(NOW)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const HOUR_MS = 3_600_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A feed's lifetime as a value object: either a positive number of hours or
|
||||||
|
* "never". `resolveExpiry(now)` turns it into an absolute `expires_at` instant
|
||||||
|
* (or undefined for a feed that never expires).
|
||||||
|
*
|
||||||
|
* Which lifetime applies — client request vs. server-side `FEED_TTL_HOURS`
|
||||||
|
* override — is the application layer's policy; it builds the VO and hands it to
|
||||||
|
* the aggregate. The aggregate never parses env config or reaches for a clock to
|
||||||
|
* compute expiry itself.
|
||||||
|
*/
|
||||||
|
export class Lifetime {
|
||||||
|
private constructor(private readonly hours: number | undefined) {}
|
||||||
|
|
||||||
|
/** A finite, positive lifetime. Non-positive/non-finite inputs collapse to never. */
|
||||||
|
static ofHours(hours: number): Lifetime {
|
||||||
|
return new Lifetime(hours);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A feed that never expires. */
|
||||||
|
static readonly never = new Lifetime(undefined);
|
||||||
|
|
||||||
|
/** The absolute expiry instant for this lifetime, or undefined if it never expires. */
|
||||||
|
resolveExpiry(now: number): number | undefined {
|
||||||
|
return this.hours !== undefined &&
|
||||||
|
Number.isFinite(this.hours) &&
|
||||||
|
this.hours > 0
|
||||||
|
? now + this.hours * HOUR_MS
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { EmailAddress } from "./email-address";
|
||||||
|
import { Domain } from "./domain";
|
||||||
|
|
||||||
|
export type SenderDecision = "accepted" | "blocked";
|
||||||
|
|
||||||
|
type SenderMatch = "blocked" | "allowed" | "neutral";
|
||||||
|
|
||||||
|
function normalizeEmail(value: string): string {
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDomains(entries: string[]): Domain[] {
|
||||||
|
return entries
|
||||||
|
.map((e) => Domain.parse(e))
|
||||||
|
.filter((d): d is Domain => d !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sender allow/block policy as a value object, built ONCE from a feed's
|
||||||
|
* lists. The exact-vs-domain split is pre-computed here so `decide` is a cheap
|
||||||
|
* lookup per candidate sender instead of re-parsing both lists for every one
|
||||||
|
* (the previous `applySenderPolicy` was O(senders × lists)).
|
||||||
|
*
|
||||||
|
* Semantics (unchanged): no lists ⇒ everything accepted; a blocklist hit always
|
||||||
|
* rejects; an allowlist (when present) must be matched by at least one sender.
|
||||||
|
*/
|
||||||
|
export class SenderPolicy {
|
||||||
|
private constructor(
|
||||||
|
private readonly exactAllowed: string[],
|
||||||
|
private readonly exactBlocked: string[],
|
||||||
|
private readonly domainAllowed: Domain[],
|
||||||
|
private readonly domainBlocked: Domain[],
|
||||||
|
private readonly hasAllowlist: boolean,
|
||||||
|
private readonly hasAnyRule: boolean,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
static fromLists(
|
||||||
|
allowed: string[] = [],
|
||||||
|
blocked: string[] = [],
|
||||||
|
): SenderPolicy {
|
||||||
|
const allowedSenders = allowed.map(normalizeEmail).filter(Boolean);
|
||||||
|
const blockedSenders = blocked.map(normalizeEmail).filter(Boolean);
|
||||||
|
return new SenderPolicy(
|
||||||
|
allowedSenders.filter((e) => e.includes("@")),
|
||||||
|
blockedSenders.filter((e) => e.includes("@")),
|
||||||
|
toDomains(allowedSenders.filter((e) => !e.includes("@"))),
|
||||||
|
toDomains(blockedSenders.filter((e) => !e.includes("@"))),
|
||||||
|
allowedSenders.length > 0,
|
||||||
|
allowedSenders.length > 0 || blockedSenders.length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private evaluate(sender: string): SenderMatch {
|
||||||
|
const parsed = EmailAddress.parse(sender);
|
||||||
|
const normalized = parsed ? parsed.normalized : normalizeEmail(sender);
|
||||||
|
const senderDomain = parsed?.domain ?? null;
|
||||||
|
|
||||||
|
if (this.exactBlocked.includes(normalized)) return "blocked";
|
||||||
|
if (this.exactAllowed.includes(normalized)) return "allowed";
|
||||||
|
if (senderDomain && this.domainBlocked.some((d) => d.matches(senderDomain)))
|
||||||
|
return "blocked";
|
||||||
|
if (senderDomain && this.domainAllowed.some((d) => d.matches(senderDomain)))
|
||||||
|
return "allowed";
|
||||||
|
return "neutral";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether an inbound email is accepted, given its candidate sender
|
||||||
|
* addresses. A blocklist hit on any sender rejects; with an allowlist set, at
|
||||||
|
* least one sender must match it.
|
||||||
|
*/
|
||||||
|
decide(senders: string[]): SenderDecision {
|
||||||
|
if (!this.hasAnyRule) return "accepted";
|
||||||
|
|
||||||
|
const accepted = senders.some((sender) => {
|
||||||
|
const decision = this.evaluate(sender);
|
||||||
|
if (decision === "allowed") return true;
|
||||||
|
if (decision === "blocked") return false;
|
||||||
|
return !this.hasAllowlist;
|
||||||
|
});
|
||||||
|
|
||||||
|
return accepted ? "accepted" : "blocked";
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-4
@@ -12,11 +12,11 @@ function req(path: string, init: RequestInit = {}): Request {
|
|||||||
describe("CORS middleware", () => {
|
describe("CORS middleware", () => {
|
||||||
it("adds CORS headers for an allowed origin", async () => {
|
it("adds CORS headers for an allowed origin", async () => {
|
||||||
const res = await worker.fetch(
|
const res = await worker.fetch(
|
||||||
req("/rss/some-feed", { headers: { Origin: "https://getmynews.app" } }),
|
req("/rss/some-feed", { headers: { Origin: "https://kill-the.news" } }),
|
||||||
env as unknown as Env,
|
env as unknown as Env,
|
||||||
);
|
);
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
|
||||||
"https://getmynews.app",
|
"https://kill-the.news",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ describe("CORS middleware", () => {
|
|||||||
req("/rss/some-feed", {
|
req("/rss/some-feed", {
|
||||||
method: "OPTIONS",
|
method: "OPTIONS",
|
||||||
headers: {
|
headers: {
|
||||||
Origin: "https://getmynews.app",
|
Origin: "https://kill-the.news",
|
||||||
"Access-Control-Request-Method": "GET",
|
"Access-Control-Request-Method": "GET",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -41,7 +41,16 @@ describe("CORS middleware", () => {
|
|||||||
);
|
);
|
||||||
expect(res.status).toBe(204);
|
expect(res.status).toBe(204);
|
||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
|
||||||
"https://getmynews.app",
|
"https://kill-the.news",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("makes /api/v1/stats readable from any origin", async () => {
|
||||||
|
const res = await worker.fetch(
|
||||||
|
req("/api/v1/stats", { headers: { Origin: "https://example.com" } }),
|
||||||
|
env as unknown as Env,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+65
-15
@@ -6,15 +6,28 @@ import { handle as handleAtom } from "./routes/atom";
|
|||||||
import { handle as handleAdmin } from "./routes/admin";
|
import { handle as handleAdmin } from "./routes/admin";
|
||||||
import { handle as handleEntry } from "./routes/entries";
|
import { handle as handleEntry } from "./routes/entries";
|
||||||
import { handle as handleFiles } from "./routes/files";
|
import { handle as handleFiles } from "./routes/files";
|
||||||
|
import { handle as handleHome } from "./routes/home";
|
||||||
|
import { handle as handleFavicon, handleFeedFavicon } from "./routes/favicon";
|
||||||
import { hubRouter } from "./routes/hub";
|
import { hubRouter } from "./routes/hub";
|
||||||
import { handleCloudflareEmail } from "./lib/cloudflare-email";
|
import { apiApp } from "./routes/api";
|
||||||
|
import { handleCloudflareEmail } from "./infrastructure/cloudflare-email";
|
||||||
import { Env } from "./types";
|
import { Env } from "./types";
|
||||||
import { logger } from "./lib/logger";
|
import { logger } from "./infrastructure/logger";
|
||||||
|
import { FeedRepository } from "./infrastructure/feed-repository";
|
||||||
|
import { purgeExpiredFeeds } from "./application/feed-cleanup";
|
||||||
|
import { FeedId } from "./domain/value-objects/feed-id";
|
||||||
|
import {
|
||||||
|
bumpCounters,
|
||||||
|
scanR2Usage,
|
||||||
|
scanKvUsage,
|
||||||
|
setStorageSnapshot,
|
||||||
|
} from "./application/stats";
|
||||||
|
import { getAttachmentBucket } from "./infrastructure/attachments";
|
||||||
import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants";
|
import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
|
|
||||||
const ALLOWED_ORIGINS = ["https://getmynews.app", "https://www.getmynews.app"];
|
const ALLOWED_ORIGINS = ["https://kill-the.news", "https://www.kill-the.news"];
|
||||||
|
|
||||||
// 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 = [
|
||||||
@@ -149,6 +162,8 @@ admin.route("/", handleAdmin);
|
|||||||
|
|
||||||
// Mount the route groups
|
// Mount the route groups
|
||||||
app.route("/api", api);
|
app.route("/api", api);
|
||||||
|
// Versioned REST API + OpenAPI spec/docs (/api/v1/*, /api/openapi.json, /api/docs)
|
||||||
|
app.route("/api", apiApp);
|
||||||
app.route("/rss", rss);
|
app.route("/rss", rss);
|
||||||
app.route("/atom", atom);
|
app.route("/atom", atom);
|
||||||
app.route("/entries", entries);
|
app.route("/entries", entries);
|
||||||
@@ -156,11 +171,18 @@ app.route("/files", files);
|
|||||||
app.route("/admin", admin);
|
app.route("/admin", admin);
|
||||||
app.route("/hub", hubRouter);
|
app.route("/hub", hubRouter);
|
||||||
|
|
||||||
|
// Project favicon (also the fallback for the per-feed favicon)
|
||||||
|
app.get("/favicon.svg", handleFavicon);
|
||||||
|
app.get("/favicon.ico", handleFavicon); // readers/browsers that hardcode .ico
|
||||||
|
|
||||||
|
// Per-feed favicon derived from the last sender's domain
|
||||||
|
app.get("/favicon/:feedId", handleFeedFavicon);
|
||||||
|
|
||||||
// Health check endpoint for monitoring
|
// Health check endpoint for monitoring
|
||||||
app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
|
app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
|
||||||
|
|
||||||
// Root path redirects to admin dashboard
|
// Public status page (counters + link to admin)
|
||||||
app.get("/", (c) => c.redirect("/admin"));
|
app.get("/", handleHome);
|
||||||
|
|
||||||
// Catch-all for 404s
|
// Catch-all for 404s
|
||||||
app.all("*", (c) => c.text("Not Found", 404));
|
app.all("*", (c) => c.text("Not Found", 404));
|
||||||
@@ -176,16 +198,44 @@ export default {
|
|||||||
await handleCloudflareEmail(message, env, ctx);
|
await handleCloudflareEmail(message, env, ctx);
|
||||||
},
|
},
|
||||||
async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext) {
|
async scheduled(_event: ScheduledEvent, env: Env, _ctx: ExecutionContext) {
|
||||||
let cursor: string | undefined;
|
const attachmentBucket = getAttachmentBucket(env);
|
||||||
let deleted = 0;
|
const repo = FeedRepository.from(env);
|
||||||
do {
|
const feeds = await repo.listFeeds();
|
||||||
const result = await env.EMAIL_STORAGE.list({ cursor });
|
const now = Date.now();
|
||||||
await Promise.all(
|
const expiredIds = feeds
|
||||||
result.keys.map(({ name }) => env.EMAIL_STORAGE.delete(name)),
|
.filter((f) => f.expires_at !== undefined && f.expires_at <= now)
|
||||||
|
.map((f) => f.id);
|
||||||
|
|
||||||
|
for (const feedId of expiredIds) {
|
||||||
|
await purgeExpiredFeeds(
|
||||||
|
env.EMAIL_STORAGE,
|
||||||
|
FeedId.unchecked(feedId),
|
||||||
|
attachmentBucket,
|
||||||
);
|
);
|
||||||
deleted += result.keys.length;
|
}
|
||||||
cursor = result.list_complete ? undefined : result.cursor;
|
if (expiredIds.length > 0) {
|
||||||
} while (cursor);
|
await repo.removeFromListBulk(expiredIds);
|
||||||
logger.info("Demo KV reset complete", { deleted });
|
await bumpCounters(env.EMAIL_STORAGE, {
|
||||||
|
feeds_deleted: expiredIds.length,
|
||||||
|
});
|
||||||
|
logger.info("Feed TTL cleanup", { deleted: expiredIds.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the cached storage-usage snapshot for the status page / /api/v1/stats.
|
||||||
|
try {
|
||||||
|
const r2 = attachmentBucket
|
||||||
|
? await scanR2Usage(attachmentBucket)
|
||||||
|
: { bytes: 0, count: 0 };
|
||||||
|
const kv = await scanKvUsage(env.EMAIL_STORAGE);
|
||||||
|
await setStorageSnapshot(env.EMAIL_STORAGE, {
|
||||||
|
attachments_bytes: r2.bytes,
|
||||||
|
attachments_count: r2.count,
|
||||||
|
kv_bytes_estimated: kv.bytes,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error refreshing storage snapshot", {
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Env } from "../types";
|
||||||
|
|
||||||
|
// Returns the attachment bucket only when the feature is enabled, so callers can
|
||||||
|
// narrow cleanly. Attachments are on whenever R2 is bound, unless explicitly
|
||||||
|
// turned off with ATTACHMENTS_ENABLED="false".
|
||||||
|
export function getAttachmentBucket(env: Env): R2Bucket | undefined {
|
||||||
|
if (env.ATTACHMENTS_ENABLED === "false") return undefined;
|
||||||
|
return env.ATTACHMENT_BUCKET;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
import { Env } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time string comparison. Prefers the runtime's native
|
||||||
|
* `crypto.subtle.timingSafeEqual` (Cloudflare Workers) and falls back to a
|
||||||
|
* manual constant-time loop in environments that lack it (Node test runtime).
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse-proxy authentication: trusted only when both `PROXY_AUTH_SECRET` and
|
||||||
|
* `PROXY_TRUSTED_IPS` are configured, the request comes from a trusted IP, the
|
||||||
|
* shared secret matches, and a `Remote-User`/`X-Forwarded-User` is present.
|
||||||
|
*/
|
||||||
|
export function checkProxyAuth(c: Context, env: Env): boolean {
|
||||||
|
if (!env.PROXY_AUTH_SECRET || !env.PROXY_TRUSTED_IPS) return false;
|
||||||
|
|
||||||
|
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") || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
trustedIps.includes(clientIp) &&
|
||||||
|
timingSafeEqual(providedSecret, env.PROXY_AUTH_SECRET) &&
|
||||||
|
remoteUser.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication for the machine-facing REST API (`/api/v1/*`).
|
||||||
|
* Grants access when proxy auth passes OR the request carries a valid
|
||||||
|
* `Authorization: Bearer <ADMIN_PASSWORD>`. No cookie, no CSRF — token only.
|
||||||
|
*/
|
||||||
|
export async function apiAuthMiddleware(
|
||||||
|
c: Context<{ Bindings: Env }>,
|
||||||
|
next: () => Promise<void>,
|
||||||
|
): Promise<Response | void> {
|
||||||
|
const env = c.env;
|
||||||
|
|
||||||
|
if (checkProxyAuth(c, env)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = c.req.header("Authorization") ?? "";
|
||||||
|
const token = authHeader.startsWith("Bearer ")
|
||||||
|
? authHeader.slice("Bearer ".length)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (token && timingSafeEqual(token, env.ADMIN_PASSWORD)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import PostalMime from "postal-mime";
|
import PostalMime from "postal-mime";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
import { processEmail, RawAttachment } from "./email-processor";
|
import { processEmail, RawAttachment } from "../application/email-processor";
|
||||||
|
import { normalizeCid } from "../infrastructure/html-processor";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
export async function handleCloudflareEmail(
|
export async function handleCloudflareEmail(
|
||||||
message: ForwardableEmailMessage,
|
message: ForwardableEmailMessage,
|
||||||
@@ -27,9 +29,10 @@ export async function handleCloudflareEmail(
|
|||||||
filename: a.filename || "attachment",
|
filename: a.filename || "attachment",
|
||||||
contentType: a.mimeType || "application/octet-stream",
|
contentType: a.mimeType || "application/octet-stream",
|
||||||
content: a.content as ArrayBuffer,
|
content: a.content as ArrayBuffer,
|
||||||
|
contentId: normalizeCid(a.contentId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await processEmail(
|
const result = await processEmail(
|
||||||
{
|
{
|
||||||
toAddress: message.to,
|
toAddress: message.to,
|
||||||
from,
|
from,
|
||||||
@@ -43,6 +46,12 @@ export async function handleCloudflareEmail(
|
|||||||
env,
|
env,
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
logger.warn("Inbound email rejected", {
|
||||||
|
to: message.to,
|
||||||
|
reason: result.reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing Cloudflare email:", error);
|
console.error("Error processing Cloudflare email:", error);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createMockEnv } from "../test/setup";
|
||||||
|
import { CountersRepository } from "./counters-repository";
|
||||||
|
import type { Env } from "../types";
|
||||||
|
|
||||||
|
const mockEnv = () => createMockEnv() as unknown as Env;
|
||||||
|
|
||||||
|
describe("CountersRepository", () => {
|
||||||
|
it("round-trips the counters singleton", async () => {
|
||||||
|
const repo = new CountersRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
expect(await repo.getRaw()).toBeNull();
|
||||||
|
await repo.put({
|
||||||
|
feeds_created: 1,
|
||||||
|
feeds_deleted: 0,
|
||||||
|
emails_received: 2,
|
||||||
|
emails_rejected: 0,
|
||||||
|
unsubscribes_sent: 0,
|
||||||
|
});
|
||||||
|
expect(await repo.getRaw()).toMatchObject({ emails_received: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Counters, Env } from "../types";
|
||||||
|
import { STATS_KEY } from "../domain/feed-keys";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KV access for the monitoring counters singleton (`stats:counters`). The
|
||||||
|
* increment policy lives in the application layer (utils/stats.ts); this
|
||||||
|
* repository owns only the raw read/write of the blob.
|
||||||
|
*/
|
||||||
|
export class CountersRepository {
|
||||||
|
constructor(private readonly kv: KVNamespace) {}
|
||||||
|
|
||||||
|
static from(env: Env): CountersRepository {
|
||||||
|
return new CountersRepository(env.EMAIL_STORAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRaw(): Promise<Counters | null> {
|
||||||
|
return (await this.kv.get(STATS_KEY, { type: "json" })) as Counters | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(counters: Counters): Promise<void> {
|
||||||
|
await this.kv.put(STATS_KEY, JSON.stringify(counters));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { server, createMockEnv } from "../test/setup";
|
||||||
|
import {
|
||||||
|
cacheFaviconForDomain,
|
||||||
|
extractEmailDomain,
|
||||||
|
getCachedIcon,
|
||||||
|
} from "./favicon-fetcher";
|
||||||
|
import { MAX_ICON_BYTES } from "../config/constants";
|
||||||
|
|
||||||
|
const iconKey = (domain: string) => `icon:${domain}`;
|
||||||
|
import type { Env } from "../types";
|
||||||
|
|
||||||
|
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4]);
|
||||||
|
|
||||||
|
function imageResponse(bytes: Uint8Array, contentType = "image/png") {
|
||||||
|
return new HttpResponse(bytes, { headers: { "Content-Type": contentType } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("extractEmailDomain", () => {
|
||||||
|
it("parses a bare address", () => {
|
||||||
|
expect(extractEmailDomain("news@github.com")).toBe("github.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a display-form address", () => {
|
||||||
|
expect(extractEmailDomain("GitHub <news@GitHub.com>")).toBe("github.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips a trailing dot and lowercases", () => {
|
||||||
|
expect(extractEmailDomain("a@Example.COM.")).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when there is no address", () => {
|
||||||
|
expect(extractEmailDomain("not an email")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cacheFaviconForDomain", () => {
|
||||||
|
it("caches the direct /favicon.ico when available", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
server.use(
|
||||||
|
http.get("https://github.com/favicon.ico", () => imageResponse(PNG)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await cacheFaviconForDomain("github.com", env);
|
||||||
|
|
||||||
|
const record = await env.EMAIL_STORAGE.get(iconKey("github.com"), "json");
|
||||||
|
expect(record).toMatchObject({ contentType: "image/png" });
|
||||||
|
expect((record as { data: string }).data).toBeTruthy();
|
||||||
|
expect(record).not.toHaveProperty("fetchedAt");
|
||||||
|
|
||||||
|
const icon = await getCachedIcon("github.com", env);
|
||||||
|
expect(icon?.contentType).toBe("image/png");
|
||||||
|
expect(new Uint8Array(icon!.bytes)).toEqual(PNG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to DuckDuckGo when the direct icon 404s", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
server.use(
|
||||||
|
http.get("https://acme.test/favicon.ico", () =>
|
||||||
|
HttpResponse.text("nope", { status: 404 }),
|
||||||
|
),
|
||||||
|
http.get("https://icons.duckduckgo.com/ip3/acme.test.ico", () =>
|
||||||
|
imageResponse(PNG, "image/x-icon"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await cacheFaviconForDomain("acme.test", env);
|
||||||
|
|
||||||
|
const icon = await getCachedIcon("acme.test", env);
|
||||||
|
expect(icon?.contentType).toBe("image/x-icon");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a negative entry when no icon is found", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
server.use(
|
||||||
|
http.get("https://nope.test/favicon.ico", () =>
|
||||||
|
HttpResponse.text("", { status: 404 }),
|
||||||
|
),
|
||||||
|
http.get("https://icons.duckduckgo.com/ip3/nope.test.ico", () =>
|
||||||
|
HttpResponse.text("", { status: 404 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await cacheFaviconForDomain("nope.test", env);
|
||||||
|
|
||||||
|
const record = await env.EMAIL_STORAGE.get(iconKey("nope.test"), "json");
|
||||||
|
expect(record).toEqual({ data: null, contentType: "" });
|
||||||
|
expect(await getCachedIcon("nope.test", env)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects oversized responses as negative", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const big = new Uint8Array(MAX_ICON_BYTES + 1);
|
||||||
|
server.use(
|
||||||
|
http.get("https://big.test/favicon.ico", () => imageResponse(big)),
|
||||||
|
http.get("https://icons.duckduckgo.com/ip3/big.test.ico", () =>
|
||||||
|
HttpResponse.text("", { status: 404 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await cacheFaviconForDomain("big.test", env);
|
||||||
|
expect(await getCachedIcon("big.test", env)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-image content types as negative", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
server.use(
|
||||||
|
http.get("https://html.test/favicon.ico", () =>
|
||||||
|
HttpResponse.text("<html>", {
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.get("https://icons.duckduckgo.com/ip3/html.test.ico", () =>
|
||||||
|
HttpResponse.text("", { status: 404 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await cacheFaviconForDomain("html.test", env);
|
||||||
|
expect(await getCachedIcon("html.test", env)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("short-circuits when an entry already exists (no outbound fetch)", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
// Pre-seed a record; with MSW onUnhandledRequest:"error", any fetch fails.
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
iconKey("cached.test"),
|
||||||
|
JSON.stringify({ data: null, contentType: "" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
cacheFaviconForDomain("cached.test", env),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on network errors", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
server.use(
|
||||||
|
http.get("https://err.test/favicon.ico", () => HttpResponse.error()),
|
||||||
|
http.get("https://icons.duckduckgo.com/ip3/err.test.ico", () =>
|
||||||
|
HttpResponse.error(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
cacheFaviconForDomain("err.test", env),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Env } from "../types";
|
||||||
|
import {
|
||||||
|
ICON_FETCH_TIMEOUT_MS,
|
||||||
|
ICON_TTL_SECONDS,
|
||||||
|
MAX_ICON_BYTES,
|
||||||
|
} from "../config/constants";
|
||||||
|
import { IconRepository } from "./icon-repository";
|
||||||
|
import { EmailAddress } from "../domain/value-objects/email-address";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
|
interface IconRecord {
|
||||||
|
data: string | null; // base64 icon bytes, or null for a negative cache entry
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the lowercased domain from a `from` value, accepting either a bare
|
||||||
|
* address (`a@b.com`) or a display form (`Name <a@b.com>`). Returns null when
|
||||||
|
* no plausible address can be parsed.
|
||||||
|
*/
|
||||||
|
export function extractEmailDomain(from: string): string | null {
|
||||||
|
return EmailAddress.parse(from)?.domain.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = "";
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchIconFrom(
|
||||||
|
url: string,
|
||||||
|
): Promise<{ buffer: ArrayBuffer; contentType: string } | null> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
redirect: "follow",
|
||||||
|
signal: AbortSignal.timeout(ICON_FETCH_TIMEOUT_MS),
|
||||||
|
headers: { "User-Agent": "kill-the-news/1.0" },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.startsWith("image/")) return null;
|
||||||
|
|
||||||
|
const buffer = await res.arrayBuffer();
|
||||||
|
if (buffer.byteLength === 0 || buffer.byteLength > MAX_ICON_BYTES)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return { buffer, contentType: contentType.split(";")[0].trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveIcon(
|
||||||
|
domain: string,
|
||||||
|
): Promise<{ buffer: ArrayBuffer; contentType: string } | null> {
|
||||||
|
const candidates = [
|
||||||
|
`https://${domain}/favicon.ico`,
|
||||||
|
`https://icons.duckduckgo.com/ip3/${domain}.ico`,
|
||||||
|
];
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
const icon = await fetchIconFrom(url);
|
||||||
|
if (icon) return icon;
|
||||||
|
} catch {
|
||||||
|
// Try the next candidate; network/timeout errors must never propagate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve and cache the favicon for a sender domain. Idempotent and never
|
||||||
|
* throws: if a (success or negative) cache entry already exists it returns
|
||||||
|
* immediately, so callers can fire this on every email without refetching.
|
||||||
|
* The KV TTL is the sole expiry mechanism.
|
||||||
|
*/
|
||||||
|
export async function cacheFaviconForDomain(
|
||||||
|
domain: string,
|
||||||
|
env: Env,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const repo = IconRepository.from(env);
|
||||||
|
const existing = await repo.getText(domain);
|
||||||
|
if (existing !== null) return; // present (incl. negative) → nothing to do
|
||||||
|
|
||||||
|
const icon = await resolveIcon(domain);
|
||||||
|
const record: IconRecord = icon
|
||||||
|
? {
|
||||||
|
data: arrayBufferToBase64(icon.buffer),
|
||||||
|
contentType: icon.contentType,
|
||||||
|
}
|
||||||
|
: { data: null, contentType: "" };
|
||||||
|
|
||||||
|
await repo.put(domain, JSON.stringify(record), ICON_TTL_SECONDS);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("Favicon cache failed", { domain, error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a cached icon for a domain. Returns null on a miss or a negative entry.
|
||||||
|
*/
|
||||||
|
export async function getCachedIcon(
|
||||||
|
domain: string,
|
||||||
|
env: Env,
|
||||||
|
): Promise<{ bytes: ArrayBuffer; contentType: string } | null> {
|
||||||
|
const record = await IconRepository.from(env).getJson<IconRecord>(domain);
|
||||||
|
if (!record || record.data === null) return null;
|
||||||
|
return {
|
||||||
|
bytes: base64ToArrayBuffer(record.data),
|
||||||
|
contentType: record.contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -72,6 +72,17 @@ describe("generateRssFeed", () => {
|
|||||||
expect(result).toContain("<title>Test Newsletter</title>");
|
expect(result).toContain("<title>Test Newsletter</title>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes the per-feed icon as the channel <image>", () => {
|
||||||
|
const result = generateRssFeed(
|
||||||
|
mockFeedConfig,
|
||||||
|
mockEmails,
|
||||||
|
BASE_URL,
|
||||||
|
FEED_ID,
|
||||||
|
);
|
||||||
|
expect(result).toContain("<image>");
|
||||||
|
expect(result).toContain(`${BASE_URL}/favicon/${FEED_ID}`);
|
||||||
|
});
|
||||||
|
|
||||||
it("includes <enclosure> element for email with attachment", () => {
|
it("includes <enclosure> element for email with attachment", () => {
|
||||||
const result = generateRssFeed(
|
const result = generateRssFeed(
|
||||||
mockFeedConfig,
|
mockFeedConfig,
|
||||||
@@ -172,6 +183,18 @@ describe("generateAtomFeed", () => {
|
|||||||
expect(result).toContain('xmlns="http://www.w3.org/2005/Atom"');
|
expect(result).toContain('xmlns="http://www.w3.org/2005/Atom"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes the per-feed icon as <icon> and <logo>", () => {
|
||||||
|
const result = generateAtomFeed(
|
||||||
|
mockFeedConfig,
|
||||||
|
mockEmails,
|
||||||
|
BASE_URL,
|
||||||
|
FEED_ID,
|
||||||
|
);
|
||||||
|
const iconUrl = `${BASE_URL}/favicon/${FEED_ID}`;
|
||||||
|
expect(result).toContain(`<icon>${iconUrl}</icon>`);
|
||||||
|
expect(result).toContain(`<logo>${iconUrl}</logo>`);
|
||||||
|
});
|
||||||
|
|
||||||
it("contains <feed> root element", () => {
|
it("contains <feed> root element", () => {
|
||||||
const result = generateAtomFeed(
|
const result = generateAtomFeed(
|
||||||
mockFeedConfig,
|
mockFeedConfig,
|
||||||
@@ -23,9 +23,14 @@ function buildFeed(
|
|||||||
feedId: string,
|
feedId: string,
|
||||||
selfUrl?: { rss?: string; atom?: string },
|
selfUrl?: { rss?: string; atom?: string },
|
||||||
): Feed {
|
): Feed {
|
||||||
|
const iconUrl = `${baseUrl}/favicon/${feedId}`;
|
||||||
const feed = new Feed({
|
const feed = new Feed({
|
||||||
title: feedConfig.title,
|
title: feedConfig.title,
|
||||||
description: feedConfig.description || "",
|
description: feedConfig.description || "",
|
||||||
|
// Per-feed icon derived from the last sender's domain (self-falls-back to
|
||||||
|
// the project icon). image → RSS <image>/Atom <logo>; favicon → Atom <icon>.
|
||||||
|
image: iconUrl,
|
||||||
|
favicon: iconUrl,
|
||||||
// Computed dynamically so the id is always canonical regardless of what
|
// Computed dynamically so the id is always canonical regardless of what
|
||||||
// was stored in KV at feed-creation time (which may have used a stale domain).
|
// was stored in KV at feed-creation time (which may have used a stale domain).
|
||||||
id: `${baseUrl}/rss/${feedId}`,
|
id: `${baseUrl}/rss/${feedId}`,
|
||||||
@@ -49,8 +54,13 @@ function buildFeed(
|
|||||||
|
|
||||||
for (const email of emails) {
|
for (const email of emails) {
|
||||||
const entryUrl = `${baseUrl}/entries/${feedId}/${email.receivedAt}`;
|
const entryUrl = `${baseUrl}/entries/${feedId}/${email.receivedAt}`;
|
||||||
const firstAttachment = email.attachments?.[0];
|
// Inline images are rendered in the body, not surfaced as an enclosure.
|
||||||
const bodyContent = processEmailContent(email.content);
|
const firstAttachment = email.attachments?.find((a) => !a.inline);
|
||||||
|
const bodyContent = processEmailContent(
|
||||||
|
email.content,
|
||||||
|
email.attachments,
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
feed.addItem({
|
feed.addItem({
|
||||||
title: email.subject,
|
title: email.subject,
|
||||||
id: entryUrl,
|
id: entryUrl,
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import type { FeedConfig } from "../types";
|
||||||
|
|
||||||
|
const fullConfig: FeedConfig = {
|
||||||
|
title: "News",
|
||||||
|
description: "desc",
|
||||||
|
language: "en",
|
||||||
|
author: "Jane",
|
||||||
|
allowed_senders: ["a@x.com"],
|
||||||
|
blocked_senders: ["b@y.com"],
|
||||||
|
created_at: 1000,
|
||||||
|
updated_at: 2000,
|
||||||
|
expires_at: 3000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("feed-mapper", () => {
|
||||||
|
it("round-trips a full config DTO through domain state unchanged", () => {
|
||||||
|
expect(toConfigDTO(fromConfigDTO(fullConfig))).toEqual(fullConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults absent sender lists to empty arrays on the domain side", () => {
|
||||||
|
const state = fromConfigDTO({
|
||||||
|
title: "T",
|
||||||
|
language: "en",
|
||||||
|
created_at: 1,
|
||||||
|
});
|
||||||
|
expect(state.allowedSenders).toEqual([]);
|
||||||
|
expect(state.blockedSenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projects the feeds:list item from domain state", () => {
|
||||||
|
const item = toListItemDTO(
|
||||||
|
FeedId.unchecked("a.b.42"),
|
||||||
|
fromConfigDTO(fullConfig),
|
||||||
|
);
|
||||||
|
expect(item).toEqual({
|
||||||
|
id: "a.b.42",
|
||||||
|
title: "News",
|
||||||
|
description: "desc",
|
||||||
|
expires_at: 3000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { FeedConfig, FeedListItem } from "../types";
|
||||||
|
import { FeedState } from "../domain/feed-state";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The translation seam between the Feed aggregate's domain state (camelCase) and
|
||||||
|
* the persistence/edge DTOs (`FeedConfig`/`FeedListItem`, snake_case). This is
|
||||||
|
* the ONLY place outside the HTTP edge that knows the stored field names — the
|
||||||
|
* domain stays free of the storage dialect, and the repository round-trips
|
||||||
|
* through here on every load/save.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Persisted config DTO → domain state (used by `FeedRepository.load`). */
|
||||||
|
export function fromConfigDTO(dto: FeedConfig): FeedState {
|
||||||
|
return {
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description,
|
||||||
|
language: dto.language,
|
||||||
|
author: dto.author,
|
||||||
|
allowedSenders: dto.allowed_senders ?? [],
|
||||||
|
blockedSenders: dto.blocked_senders ?? [],
|
||||||
|
createdAt: dto.created_at,
|
||||||
|
updatedAt: dto.updated_at,
|
||||||
|
expiresAt: dto.expires_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Domain state → persisted config DTO (used by `FeedRepository.save`). */
|
||||||
|
export function toConfigDTO(state: FeedState): FeedConfig {
|
||||||
|
return {
|
||||||
|
title: state.title,
|
||||||
|
description: state.description,
|
||||||
|
language: state.language,
|
||||||
|
author: state.author,
|
||||||
|
allowed_senders: state.allowedSenders,
|
||||||
|
blocked_senders: state.blockedSenders,
|
||||||
|
created_at: state.createdAt,
|
||||||
|
updated_at: state.updatedAt,
|
||||||
|
expires_at: state.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Domain state → the projection cached in the global `feeds:list` registry. */
|
||||||
|
export function toListItemDTO(id: FeedId, state: FeedState): FeedListItem {
|
||||||
|
return {
|
||||||
|
id: id.value,
|
||||||
|
title: state.title,
|
||||||
|
description: state.description,
|
||||||
|
expires_at: state.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createMockEnv } from "../test/setup";
|
||||||
|
import { FeedRepository } from "./feed-repository";
|
||||||
|
import { Feed } from "../domain/feed.aggregate";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
|
||||||
|
|
||||||
|
const mockEnv = () => createMockEnv() as unknown as Env;
|
||||||
|
const fid = (value: string) => FeedId.unchecked(value);
|
||||||
|
|
||||||
|
const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({
|
||||||
|
title: "Test Feed",
|
||||||
|
language: "en",
|
||||||
|
created_at: 1000,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sampleEmail = (overrides: Partial<EmailData> = {}): EmailData => ({
|
||||||
|
subject: "Hello",
|
||||||
|
from: "news@example.com",
|
||||||
|
content: "<p>hi</p>",
|
||||||
|
receivedAt: 1234,
|
||||||
|
headers: {},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FeedRepository key schema", () => {
|
||||||
|
it("builds the canonical KV keys via the public API", () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
expect(repo.feedKeyPrefix(fid("a.b.42"))).toBe("feed:a.b.42:");
|
||||||
|
expect(repo.newEmailKey(fid("a.b.42"))).toMatch(/^feed:a\.b\.42:\d+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises email keys vs config/metadata keys", () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
expect(repo.isEmailKey(fid("a.b.42"), "feed:a.b.42:config")).toBe(false);
|
||||||
|
expect(repo.isEmailKey(fid("a.b.42"), "feed:a.b.42:metadata")).toBe(false);
|
||||||
|
expect(repo.isEmailKey(fid("a.b.42"), "feed:a.b.42:1700000000000")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers the feed id from an email key", () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
expect(repo.feedIdFromEmailKey("feed:a.b.42:1700000000000")).toBe("a.b.42");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FeedRepository config & metadata", () => {
|
||||||
|
it("round-trips and deletes a feed config", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
expect(await repo.getConfig(fid("a.b.42"))).toBeNull();
|
||||||
|
await repo.putConfig(fid("a.b.42"), sampleConfig());
|
||||||
|
expect(await repo.getConfig(fid("a.b.42"))).toMatchObject({
|
||||||
|
title: "Test Feed",
|
||||||
|
});
|
||||||
|
await repo.deleteConfig(fid("a.b.42"));
|
||||||
|
expect(await repo.getConfig(fid("a.b.42"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips and deletes feed metadata", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
const meta: FeedMetadata = { emails: [] };
|
||||||
|
await repo.putMetadata(fid("a.b.42"), meta);
|
||||||
|
expect(await repo.getMetadata(fid("a.b.42"))).toEqual(meta);
|
||||||
|
await repo.deleteMetadata(fid("a.b.42"));
|
||||||
|
expect(await repo.getMetadata(fid("a.b.42"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FeedRepository emails", () => {
|
||||||
|
it("stores and reads an email under a minted key", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
const key = repo.newEmailKey(fid("a.b.42"));
|
||||||
|
await repo.putEmail(key, sampleEmail());
|
||||||
|
expect(await repo.getEmail(key)).toMatchObject({ subject: "Hello" });
|
||||||
|
await repo.deleteEmail(key);
|
||||||
|
expect(await repo.getEmail(key)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists every key under a feed prefix", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
await repo.putConfig(fid("a.b.42"), sampleConfig());
|
||||||
|
await repo.putMetadata(fid("a.b.42"), { emails: [] });
|
||||||
|
const emailKey = repo.newEmailKey(fid("a.b.42"));
|
||||||
|
await repo.putEmail(emailKey, sampleEmail());
|
||||||
|
|
||||||
|
const listed = await repo.listFeedKeys(fid("a.b.42"));
|
||||||
|
expect(listed.names).toContain("feed:a.b.42:config");
|
||||||
|
expect(listed.names).toContain("feed:a.b.42:metadata");
|
||||||
|
expect(listed.names).toContain(emailKey);
|
||||||
|
expect(
|
||||||
|
listed.names.filter((k) => repo.isEmailKey(fid("a.b.42"), k)),
|
||||||
|
).toEqual([emailKey]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FeedRepository feed list", () => {
|
||||||
|
const feedWith = (
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
opts: { description?: string; expires_at?: number } = {},
|
||||||
|
) =>
|
||||||
|
Feed.reconstitute(
|
||||||
|
fid(id),
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
createdAt: 1000,
|
||||||
|
description: opts.description,
|
||||||
|
expiresAt: opts.expires_at,
|
||||||
|
},
|
||||||
|
{ emails: [] },
|
||||||
|
);
|
||||||
|
|
||||||
|
it("upserts the list entry from the aggregate on save/saveConfig", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
await repo.save(
|
||||||
|
feedWith("a.b.42", "One", { description: "desc", expires_at: 5000 }),
|
||||||
|
);
|
||||||
|
await repo.save(feedWith("c.d.99", "Two"));
|
||||||
|
|
||||||
|
let feeds = await repo.listFeeds();
|
||||||
|
expect(feeds).toHaveLength(2);
|
||||||
|
expect(feeds.find((f) => f.id === "a.b.42")).toMatchObject({
|
||||||
|
title: "One",
|
||||||
|
expires_at: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// saveConfig refreshes the same entry in place (no duplicate, expiry cleared).
|
||||||
|
await repo.saveConfig(feedWith("a.b.42", "One-updated"));
|
||||||
|
feeds = await repo.listFeeds();
|
||||||
|
expect(feeds.filter((f) => f.id === "a.b.42")).toHaveLength(1);
|
||||||
|
const updated = feeds.find((f) => f.id === "a.b.42");
|
||||||
|
expect(updated).toMatchObject({ title: "One-updated" });
|
||||||
|
expect(updated?.expires_at).toBeUndefined();
|
||||||
|
|
||||||
|
expect(await repo.removeFromList(fid("a.b.42"))).toBe(true);
|
||||||
|
expect(await repo.removeFromList(fid("missing"))).toBe(false);
|
||||||
|
feeds = await repo.listFeeds();
|
||||||
|
expect(feeds.map((f) => f.id)).toEqual(["c.d.99"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bulk-removes only the matching ids", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
await repo.save(feedWith("a.b.42", "One"));
|
||||||
|
await repo.save(feedWith("c.d.99", "Two"));
|
||||||
|
await repo.save(feedWith("e.f.10", "Three"));
|
||||||
|
|
||||||
|
const removed = await repo.removeFromListBulk(["a.b.42", "e.f.10", "nope"]);
|
||||||
|
expect(removed.sort()).toEqual(["a.b.42", "e.f.10"]);
|
||||||
|
expect((await repo.listFeeds()).map((f) => f.id)).toEqual(["c.d.99"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import {
|
||||||
|
EmailData,
|
||||||
|
Env,
|
||||||
|
FeedConfig,
|
||||||
|
FeedList,
|
||||||
|
FeedListItem,
|
||||||
|
FeedMetadata,
|
||||||
|
} from "../types";
|
||||||
|
import { FEEDS_LIST_KEY } from "../config/constants";
|
||||||
|
import { feedKeys } from "../domain/feed-keys";
|
||||||
|
import { Feed } from "../domain/feed.aggregate";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for KV access to the Feed aggregate. The key schema
|
||||||
|
* itself lives in `feed-keys.ts`; this repository owns the get/put operations.
|
||||||
|
* No other module should build a `feed:`/`feeds:list`/`websub:`/`icon:`/
|
||||||
|
* `stats:counters` key string — go through `feed-keys` or a repository method.
|
||||||
|
*
|
||||||
|
* Wraps one `KVNamespace`; construct per request via `FeedRepository.from(env)`.
|
||||||
|
*/
|
||||||
|
export class FeedRepository {
|
||||||
|
constructor(private readonly kv: KVNamespace) {}
|
||||||
|
|
||||||
|
static from(env: Env): FeedRepository {
|
||||||
|
return new FeedRepository(env.EMAIL_STORAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key schema (delegates to feed-keys) ───────────────────────────────────
|
||||||
|
|
||||||
|
private configKey(feedId: FeedId): string {
|
||||||
|
return feedKeys.config(feedId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private metadataKey(feedId: FeedId): string {
|
||||||
|
return feedKeys.metadata(feedId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefix covering every key owned by a feed (config, metadata, emails). */
|
||||||
|
feedKeyPrefix(feedId: FeedId): string {
|
||||||
|
return feedKeys.feedPrefix(feedId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mint a fresh, time-ordered email key. Call once and reuse the result. */
|
||||||
|
newEmailKey(feedId: FeedId): string {
|
||||||
|
return feedKeys.newEmail(feedId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when `key` is an email entry (not the feed's config/metadata key). */
|
||||||
|
isEmailKey(feedId: FeedId, key: string): boolean {
|
||||||
|
return feedKeys.isEmail(feedId.value, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recover the feed id embedded in an email key (`feed:<id>:<ts>`). */
|
||||||
|
feedIdFromEmailKey(key: string): string {
|
||||||
|
return feedKeys.feedIdFromEmail(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feed aggregate ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the aggregate (config + email index). A feed exists iff it has a
|
||||||
|
* config; metadata defaults to empty so a freshly-created feed still loads.
|
||||||
|
*/
|
||||||
|
async load(feedId: FeedId): Promise<Feed | null> {
|
||||||
|
const [config, metadata] = await Promise.all([
|
||||||
|
this.getConfig(feedId),
|
||||||
|
this.getMetadata(feedId),
|
||||||
|
]);
|
||||||
|
if (!config) return null;
|
||||||
|
return Feed.reconstitute(
|
||||||
|
feedId,
|
||||||
|
fromConfigDTO(config),
|
||||||
|
metadata ?? { emails: [] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist both keys the aggregate owns (config + metadata) and keep the global
|
||||||
|
* `feeds:list` entry in sync. Config/list DTOs are derived from the aggregate's
|
||||||
|
* domain `state()` via `feed-mapper`, so no caller has to mirror snake_case.
|
||||||
|
*/
|
||||||
|
async save(feed: Feed): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this.putConfig(feed.id, toConfigDTO(feed.state())),
|
||||||
|
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
|
||||||
|
this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist only the email index. Used by the ingest/delete paths where config
|
||||||
|
* is unchanged — avoids a redundant config write on the hot path. The list
|
||||||
|
* projection (title/description/expiry) is untouched, so it is not rewritten.
|
||||||
|
*/
|
||||||
|
async saveMetadata(feed: Feed): Promise<void> {
|
||||||
|
await this.putMetadata(feed.id, feed.toMetadataSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist only the config and refresh the `feeds:list` entry from it. Used by
|
||||||
|
* the rename/edit paths where metadata is unchanged — avoids re-writing (and
|
||||||
|
* risking clobbering) the email index.
|
||||||
|
*/
|
||||||
|
async saveConfig(feed: Feed): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this.putConfig(feed.id, toConfigDTO(feed.state())),
|
||||||
|
this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feed config ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getConfig(feedId: FeedId): Promise<FeedConfig | null> {
|
||||||
|
return (await this.kv.get(this.configKey(feedId), {
|
||||||
|
type: "json",
|
||||||
|
})) as FeedConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async putConfig(feedId: FeedId, config: FeedConfig): Promise<void> {
|
||||||
|
await this.kv.put(this.configKey(feedId), JSON.stringify(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteConfig(feedId: FeedId): Promise<void> {
|
||||||
|
await this.kv.delete(this.configKey(feedId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Feed metadata ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getMetadata(feedId: FeedId): Promise<FeedMetadata | null> {
|
||||||
|
return (await this.kv.get(this.metadataKey(feedId), {
|
||||||
|
type: "json",
|
||||||
|
})) as FeedMetadata | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async putMetadata(feedId: FeedId, metadata: FeedMetadata): Promise<void> {
|
||||||
|
await this.kv.put(this.metadataKey(feedId), JSON.stringify(metadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMetadata(feedId: FeedId): Promise<void> {
|
||||||
|
await this.kv.delete(this.metadataKey(feedId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Emails ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async putEmail(key: string, data: EmailData): Promise<void> {
|
||||||
|
await this.kv.put(key, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmail(key: string): Promise<EmailData | null> {
|
||||||
|
return (await this.kv.get(key, { type: "json" })) as EmailData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEmail(key: string): Promise<void> {
|
||||||
|
await this.kv.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Global feed list ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listFeeds(): Promise<FeedListItem[]> {
|
||||||
|
try {
|
||||||
|
const feedList = (await this.kv.get(FEEDS_LIST_KEY, {
|
||||||
|
type: "json",
|
||||||
|
})) as FeedList | null;
|
||||||
|
return feedList?.feeds || [];
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error listing feeds", { error: String(error) });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert-or-update a feed's entry in the global `feeds:list` registry from its
|
||||||
|
* aggregate summary. Idempotent by feed id. Private: callers persist a `Feed`
|
||||||
|
* via `save`/`saveConfig`, which keep the projection in sync — never mirror the
|
||||||
|
* list by hand. (Read-modify-write is not atomic under KV, unchanged from the
|
||||||
|
* prior add/update split.)
|
||||||
|
*/
|
||||||
|
private async upsertListEntry(summary: FeedListItem): Promise<void> {
|
||||||
|
try {
|
||||||
|
const feedList = ((await this.kv.get(FEEDS_LIST_KEY, {
|
||||||
|
type: "json",
|
||||||
|
})) as FeedList | null) || { feeds: [] };
|
||||||
|
|
||||||
|
const index = feedList.feeds.findIndex((feed) => feed.id === summary.id);
|
||||||
|
if (index === -1) {
|
||||||
|
feedList.feeds.push(summary);
|
||||||
|
} else {
|
||||||
|
feedList.feeds[index] = summary;
|
||||||
|
}
|
||||||
|
await this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error upserting feed in list", {
|
||||||
|
feedId: summary.id,
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFromListBulk(feedIds: string[]): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const feedList = ((await this.kv.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 this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
|
||||||
|
return removed;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error removing feeds from list", { error: String(error) });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFromList(feedId: FeedId): Promise<boolean> {
|
||||||
|
const removed = await this.removeFromListBulk([feedId.value]);
|
||||||
|
return removed.includes(feedId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key listing / counting ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listFeedKeys(
|
||||||
|
feedId: FeedId,
|
||||||
|
options: { cursor?: string; limit?: number } = {},
|
||||||
|
): Promise<{ names: string[]; cursor: string; listComplete: boolean }> {
|
||||||
|
const prefix = this.feedKeyPrefix(feedId);
|
||||||
|
const limit = Math.min(1000, Math.max(1, Math.floor(options.limit || 100)));
|
||||||
|
const cursor = options.cursor || undefined;
|
||||||
|
|
||||||
|
const listed = await this.kv.list({ prefix, cursor, limit });
|
||||||
|
return {
|
||||||
|
names: (listed.keys || []).map((k) => k.name),
|
||||||
|
cursor: listed.cursor || "",
|
||||||
|
listComplete: !!listed.list_complete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async countKeysByPrefix(prefix: string): Promise<number> {
|
||||||
|
let total = 0;
|
||||||
|
let cursor: string | undefined;
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const listed = await this.kv.list({ prefix, cursor, limit: 1000 });
|
||||||
|
total += listed.keys.length;
|
||||||
|
cursor = listed.list_complete ? undefined : listed.cursor;
|
||||||
|
} while (cursor);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error counting keys", { prefix, error: String(error) });
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,35 @@
|
|||||||
import { EmailParser } from "../utils/email-parser";
|
import { EmailParser } from "../domain/email-parser";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
import { processEmail, RawAttachment } from "./email-processor";
|
import {
|
||||||
|
processEmail,
|
||||||
|
IngestResult,
|
||||||
|
RawAttachment,
|
||||||
|
} from "../application/email-processor";
|
||||||
|
import { normalizeCid } from "../infrastructure/html-processor";
|
||||||
|
|
||||||
|
/** Map an ingestion result to the HTTP response ForwardEmail expects. */
|
||||||
|
export function ingestResultToResponse(result: IngestResult): Response {
|
||||||
|
if (result.ok) {
|
||||||
|
return new Response("Email processed successfully", { status: 200 });
|
||||||
|
}
|
||||||
|
switch (result.reason) {
|
||||||
|
case "invalid_address":
|
||||||
|
return new Response("Invalid email address format", { status: 400 });
|
||||||
|
case "feed_not_found":
|
||||||
|
return new Response("Feed does not exist", { status: 404 });
|
||||||
|
case "feed_expired":
|
||||||
|
return new Response("Feed has expired", { status: 410 });
|
||||||
|
case "sender_blocked":
|
||||||
|
return new Response("Sender not allowed for this feed", { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface ForwardEmailAttachment {
|
export interface ForwardEmailAttachment {
|
||||||
filename?: string;
|
filename?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
|
cid?: string;
|
||||||
|
contentId?: string;
|
||||||
content?: { type: "Buffer"; data: number[] } | ArrayBuffer | ArrayBufferView;
|
content?: { type: "Buffer"; data: number[] } | ArrayBuffer | ArrayBufferView;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,18 +97,19 @@ export async function handleForwardEmail(
|
|||||||
const emailData = EmailParser.parseForwardEmailPayload(payload);
|
const emailData = EmailParser.parseForwardEmailPayload(payload);
|
||||||
|
|
||||||
const rawAttachments: RawAttachment[] = (payload.attachments ?? [])
|
const rawAttachments: RawAttachment[] = (payload.attachments ?? [])
|
||||||
.map((a) => {
|
.map((a): RawAttachment | null => {
|
||||||
const buffer = toArrayBuffer(a.content);
|
const buffer = toArrayBuffer(a.content);
|
||||||
if (!buffer) return null;
|
if (!buffer) return null;
|
||||||
return {
|
return {
|
||||||
filename: a.filename || "attachment",
|
filename: a.filename || "attachment",
|
||||||
contentType: a.contentType || "application/octet-stream",
|
contentType: a.contentType || "application/octet-stream",
|
||||||
content: buffer,
|
content: buffer,
|
||||||
|
contentId: normalizeCid(a.cid ?? a.contentId),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((a): a is RawAttachment => a !== null);
|
.filter((a): a is RawAttachment => a !== null);
|
||||||
|
|
||||||
return processEmail(
|
const result = await processEmail(
|
||||||
{
|
{
|
||||||
toAddress: payload.recipients?.[0] || "",
|
toAddress: payload.recipients?.[0] || "",
|
||||||
from: emailData.from,
|
from: emailData.from,
|
||||||
@@ -98,4 +123,5 @@ export async function handleForwardEmail(
|
|||||||
env,
|
env,
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
return ingestResultToResponse(result);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { processEmailContent } from "./html-processor";
|
import { processEmailContent, extractInlineCids } from "./html-processor";
|
||||||
|
import type { AttachmentData } from "../types";
|
||||||
|
|
||||||
describe("processEmailContent — body extraction", () => {
|
describe("processEmailContent — body extraction", () => {
|
||||||
it("extracts content inside <body> tags", () => {
|
it("extracts content inside <body> tags", () => {
|
||||||
@@ -123,3 +124,95 @@ describe("processEmailContent — mso style cleanup", () => {
|
|||||||
expect(result).not.toContain("mso-font-size");
|
expect(result).not.toContain("mso-font-size");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("processEmailContent — inline cid: rewriting", () => {
|
||||||
|
const attachment = (
|
||||||
|
overrides: Partial<AttachmentData> = {},
|
||||||
|
): AttachmentData => ({
|
||||||
|
id: "att-123",
|
||||||
|
filename: "chicken big.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
size: 100,
|
||||||
|
contentId: "ii_mpi85rqy0",
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites cid: src to a relative /files URL when no baseUrl", () => {
|
||||||
|
const html = '<body><img src="cid:ii_mpi85rqy0" alt="x"/></body>';
|
||||||
|
const result = processEmailContent(html, [attachment()]);
|
||||||
|
expect(result).toContain('src="/files/att-123/chicken%20big.png"');
|
||||||
|
expect(result).not.toContain("cid:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites cid: src to an absolute URL when baseUrl is given", () => {
|
||||||
|
const html = '<body><img src="cid:ii_mpi85rqy0"/></body>';
|
||||||
|
const result = processEmailContent(
|
||||||
|
html,
|
||||||
|
[attachment()],
|
||||||
|
"https://feed.example",
|
||||||
|
);
|
||||||
|
expect(result).toContain(
|
||||||
|
'src="https://feed.example/files/att-123/chicken%20big.png"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a stored Content-ID that has angle brackets", () => {
|
||||||
|
const html = '<body><img src="cid:ii_mpi85rqy0"/></body>';
|
||||||
|
const result = processEmailContent(html, [
|
||||||
|
attachment({ contentId: "<ii_mpi85rqy0>" }),
|
||||||
|
]);
|
||||||
|
expect(result).toContain('src="/files/att-123/chicken%20big.png"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive on the cid: scheme", () => {
|
||||||
|
const html = '<body><img src="CID:ii_mpi85rqy0"/></body>';
|
||||||
|
const result = processEmailContent(html, [attachment()]);
|
||||||
|
expect(result).toContain('src="/files/att-123/chicken%20big.png"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unknown cid references unchanged", () => {
|
||||||
|
const html = '<body><img src="cid:unknown"/></body>';
|
||||||
|
const result = processEmailContent(html, [attachment()]);
|
||||||
|
expect(result).toContain('src="cid:unknown"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves cid references unchanged when no attachments are provided", () => {
|
||||||
|
const html = '<body><img src="cid:ii_mpi85rqy0"/></body>';
|
||||||
|
const result = processEmailContent(html);
|
||||||
|
expect(result).toContain('src="cid:ii_mpi85rqy0"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores attachments without a contentId", () => {
|
||||||
|
const html = '<body><img src="cid:ii_mpi85rqy0"/></body>';
|
||||||
|
const result = processEmailContent(html, [
|
||||||
|
attachment({ contentId: undefined }),
|
||||||
|
]);
|
||||||
|
expect(result).toContain('src="cid:ii_mpi85rqy0"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not touch normal http image sources", () => {
|
||||||
|
const html = '<body><img src="https://example.com/a.png"/></body>';
|
||||||
|
const result = processEmailContent(html, [attachment()]);
|
||||||
|
expect(result).toContain('src="https://example.com/a.png"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractInlineCids", () => {
|
||||||
|
it("collects normalized cids referenced by cid: image sources", () => {
|
||||||
|
const html = '<body><img src="cid:ii_abc"/><img src="CID:ii_def"/></body>';
|
||||||
|
expect(extractInlineCids(html)).toEqual(new Set(["ii_abc", "ii_def"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-cid sources", () => {
|
||||||
|
const html = '<body><img src="https://example.com/a.png"/></body>';
|
||||||
|
expect(extractInlineCids(html).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty set for plain text", () => {
|
||||||
|
expect(extractInlineCids("just text, no html").size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty set for empty input", () => {
|
||||||
|
expect(extractInlineCids("").size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import escapeHtml from "escape-html";
|
||||||
|
import type { AttachmentData } from "../types";
|
||||||
|
|
||||||
|
// Strip surrounding angle brackets and whitespace from a Content-ID so that a
|
||||||
|
// stored value like "<ii_mpi85rqy0>" matches an HTML reference "cid:ii_mpi85rqy0".
|
||||||
|
export function normalizeCid(
|
||||||
|
cid: string | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!cid) return undefined;
|
||||||
|
const trimmed = cid.trim().replace(/^<|>$/g, "").trim();
|
||||||
|
return trimmed || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the normalized Content-IDs referenced by `cid:` image sources in the
|
||||||
|
// email body — exactly the set rewriteCidSrc would turn into inline <img> URLs.
|
||||||
|
// Used at ingest to flag those attachments as inline (rendered in place, hidden
|
||||||
|
// from the downloadable attachment lists).
|
||||||
|
export function extractInlineCids(content: string): Set<string> {
|
||||||
|
const cids = new Set<string>();
|
||||||
|
if (!content || isPlainText(content)) return cids;
|
||||||
|
const { document } = parseHTML(content);
|
||||||
|
document.querySelectorAll("[src]").forEach((el: Element) => {
|
||||||
|
const match = (el.getAttribute("src") ?? "").match(/^\s*cid:(.+)$/i);
|
||||||
|
const cid = match ? normalizeCid(match[1]) : undefined;
|
||||||
|
if (cid) cids.add(cid);
|
||||||
|
});
|
||||||
|
return cids;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 rewriteCidSrc(
|
||||||
|
el: Element,
|
||||||
|
cidMap: Map<string, AttachmentData>,
|
||||||
|
baseUrl: string,
|
||||||
|
): void {
|
||||||
|
const src = el.getAttribute("src") ?? "";
|
||||||
|
const match = src.match(/^\s*cid:(.+)$/i);
|
||||||
|
if (!match) return;
|
||||||
|
const attachment = cidMap.get(normalizeCid(match[1]) ?? "");
|
||||||
|
if (!attachment) return;
|
||||||
|
el.setAttribute(
|
||||||
|
"src",
|
||||||
|
`${baseUrl}/files/${attachment.id}/${encodeURIComponent(attachment.filename)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
* - Rewrites inline cid: image refs to the stored attachment URL. baseUrl=""
|
||||||
|
* yields relative URLs (entry page, same origin); a baseUrl yields absolute
|
||||||
|
* URLs (feeds, for external RSS readers).
|
||||||
|
*/
|
||||||
|
export function processEmailContent(
|
||||||
|
content: string,
|
||||||
|
attachments?: AttachmentData[],
|
||||||
|
baseUrl = "",
|
||||||
|
): string {
|
||||||
|
if (!content) return "";
|
||||||
|
|
||||||
|
if (isPlainText(content)) {
|
||||||
|
return `<pre style="white-space: pre-wrap; word-break: break-word;">${escapeHtml(content)}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cidMap = new Map<string, AttachmentData>();
|
||||||
|
for (const att of attachments ?? []) {
|
||||||
|
const cid = normalizeCid(att.contentId);
|
||||||
|
if (cid) cidMap.set(cid, att);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { document } = parseHTML(content);
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelectorAll("script, object, embed, iframe, frame, frameset")
|
||||||
|
.forEach((el: Element) => el.remove());
|
||||||
|
|
||||||
|
document.querySelectorAll("*").forEach((el: Element) => sanitizeElement(el));
|
||||||
|
|
||||||
|
if (cidMap.size > 0) {
|
||||||
|
document
|
||||||
|
.querySelectorAll("[src]")
|
||||||
|
.forEach((el: Element) => rewriteCidSrc(el, cidMap, baseUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full documents expose a <body>; bodyless fragments are serialized directly
|
||||||
|
// so that sanitization and cid rewriting still apply to their nodes.
|
||||||
|
const body = document.querySelector("body");
|
||||||
|
return body ? body.innerHTML : document.toString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createMockEnv } from "../test/setup";
|
||||||
|
import { IconRepository } from "./icon-repository";
|
||||||
|
import type { Env } from "../types";
|
||||||
|
|
||||||
|
const mockEnv = () => createMockEnv() as unknown as Env;
|
||||||
|
|
||||||
|
describe("IconRepository", () => {
|
||||||
|
it("stores and reads favicons as text or json under the icon: key", async () => {
|
||||||
|
const repo = new IconRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
expect(await repo.getText("example.com")).toBeNull();
|
||||||
|
await repo.put("example.com", JSON.stringify({ data: null }), 60);
|
||||||
|
expect(await repo.getText("example.com")).toBe('{"data":null}');
|
||||||
|
expect(await repo.getJson<{ data: null }>("example.com")).toEqual({
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Env } from "../types";
|
||||||
|
import { feedKeys } from "../domain/feed-keys";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KV access for cached per-domain favicons (`icon:<domain>`). Entries may be
|
||||||
|
* positive (base64 bytes) or negative (a sentinel marking a failed fetch), and
|
||||||
|
* always carry a TTL — the cache's sole expiry mechanism.
|
||||||
|
*/
|
||||||
|
export class IconRepository {
|
||||||
|
constructor(private readonly kv: KVNamespace) {}
|
||||||
|
|
||||||
|
static from(env: Env): IconRepository {
|
||||||
|
return new IconRepository(env.EMAIL_STORAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
getText(domain: string): Promise<string | null> {
|
||||||
|
return this.kv.get(feedKeys.icon(domain), "text");
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJson<T>(domain: string): Promise<T | null> {
|
||||||
|
return (await this.kv.get(feedKeys.icon(domain), {
|
||||||
|
type: "json",
|
||||||
|
})) as T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(domain: string, value: string, ttlSeconds: number): Promise<void> {
|
||||||
|
await this.kv.put(feedKeys.icon(domain), value, {
|
||||||
|
expirationTtl: ttlSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { server, createMockEnv } from "../test/setup";
|
||||||
|
import {
|
||||||
|
parseOneClickUnsubscribe,
|
||||||
|
sendOneClickUnsubscribe,
|
||||||
|
sendUnsubscribes,
|
||||||
|
} from "./unsubscribe";
|
||||||
|
import { getCounters } from "../application/stats";
|
||||||
|
import type { Env } from "../types";
|
||||||
|
|
||||||
|
const POST_HEADER = "List-Unsubscribe=One-Click";
|
||||||
|
|
||||||
|
describe("parseOneClickUnsubscribe", () => {
|
||||||
|
it("returns the https URL when the one-click Post header is present", () => {
|
||||||
|
expect(
|
||||||
|
parseOneClickUnsubscribe({
|
||||||
|
"list-unsubscribe": "<https://news.example.com/u?t=abc>",
|
||||||
|
"list-unsubscribe-post": POST_HEADER,
|
||||||
|
}),
|
||||||
|
).toBe("https://news.example.com/u?t=abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the https URL when both https and mailto are present", () => {
|
||||||
|
expect(
|
||||||
|
parseOneClickUnsubscribe({
|
||||||
|
"list-unsubscribe":
|
||||||
|
"<mailto:unsub@example.com>, <https://example.com/u/1>",
|
||||||
|
"list-unsubscribe-post": POST_HEADER,
|
||||||
|
}),
|
||||||
|
).toBe("https://example.com/u/1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a mailto-only header", () => {
|
||||||
|
expect(
|
||||||
|
parseOneClickUnsubscribe({
|
||||||
|
"list-unsubscribe": "<mailto:unsub@example.com>",
|
||||||
|
"list-unsubscribe-post": POST_HEADER,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the Post header is missing", () => {
|
||||||
|
expect(
|
||||||
|
parseOneClickUnsubscribe({
|
||||||
|
"list-unsubscribe": "<https://example.com/u/1>",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the Post header has the wrong value", () => {
|
||||||
|
expect(
|
||||||
|
parseOneClickUnsubscribe({
|
||||||
|
"list-unsubscribe": "<https://example.com/u/1>",
|
||||||
|
"list-unsubscribe-post": "List-Unsubscribe=Something",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches headers and Post value case-insensitively", () => {
|
||||||
|
expect(
|
||||||
|
parseOneClickUnsubscribe({
|
||||||
|
"List-Unsubscribe": "<https://example.com/u/1>",
|
||||||
|
"List-Unsubscribe-Post": "list-unsubscribe=ONE-CLICK",
|
||||||
|
}),
|
||||||
|
).toBe("https://example.com/u/1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores plaintext http URLs", () => {
|
||||||
|
expect(
|
||||||
|
parseOneClickUnsubscribe({
|
||||||
|
"list-unsubscribe": "<http://example.com/u/1>",
|
||||||
|
"list-unsubscribe-post": POST_HEADER,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when there are no headers", () => {
|
||||||
|
expect(parseOneClickUnsubscribe({})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendOneClickUnsubscribe", () => {
|
||||||
|
it("POSTs the one-click body and returns true on success", async () => {
|
||||||
|
let captured: { method: string; contentType: string; body: string } | null =
|
||||||
|
null;
|
||||||
|
server.use(
|
||||||
|
http.post("https://example.com/u/1", async ({ request }) => {
|
||||||
|
captured = {
|
||||||
|
method: request.method,
|
||||||
|
contentType: request.headers.get("content-type") ?? "",
|
||||||
|
body: await request.text(),
|
||||||
|
};
|
||||||
|
return HttpResponse.text("ok");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ok = await sendOneClickUnsubscribe("https://example.com/u/1");
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(captured).toEqual({
|
||||||
|
method: "POST",
|
||||||
|
contentType: "application/x-www-form-urlencoded",
|
||||||
|
body: POST_HEADER,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false on a non-ok response", async () => {
|
||||||
|
server.use(
|
||||||
|
http.post("https://example.com/u/1", () =>
|
||||||
|
HttpResponse.text("nope", { status: 404 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(await sendOneClickUnsubscribe("https://example.com/u/1")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false (no throw) on a network error", async () => {
|
||||||
|
server.use(
|
||||||
|
http.post("https://example.com/u/1", () => HttpResponse.error()),
|
||||||
|
);
|
||||||
|
expect(await sendOneClickUnsubscribe("https://example.com/u/1")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendUnsubscribes", () => {
|
||||||
|
it("de-dupes URLs and bumps unsubscribes_sent by the success count", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
let hitsOne = 0;
|
||||||
|
let hitsTwo = 0;
|
||||||
|
server.use(
|
||||||
|
http.post("https://example.com/a", () => {
|
||||||
|
hitsOne += 1;
|
||||||
|
return HttpResponse.text("ok");
|
||||||
|
}),
|
||||||
|
http.post("https://example.com/b", () => {
|
||||||
|
hitsTwo += 1;
|
||||||
|
return HttpResponse.text("ok");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendUnsubscribes(
|
||||||
|
[
|
||||||
|
"https://example.com/a",
|
||||||
|
"https://example.com/a",
|
||||||
|
"https://example.com/b",
|
||||||
|
],
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hitsOne).toBe(1);
|
||||||
|
expect(hitsTwo).toBe(1);
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE);
|
||||||
|
expect(counters.unsubscribes_sent).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only counts successful requests", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
server.use(
|
||||||
|
http.post("https://example.com/ok", () => HttpResponse.text("ok")),
|
||||||
|
http.post("https://example.com/bad", () =>
|
||||||
|
HttpResponse.text("no", { status: 500 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendUnsubscribes(
|
||||||
|
["https://example.com/ok", "https://example.com/bad"],
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE);
|
||||||
|
expect(counters.unsubscribes_sent).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing for an empty list", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
await sendUnsubscribes([], env);
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE);
|
||||||
|
expect(counters.unsubscribes_sent).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Env } from "../types";
|
||||||
|
import { UNSUBSCRIBE_TIMEOUT_MS } from "../config/constants";
|
||||||
|
import { bumpCounters } from "../application/stats";
|
||||||
|
import { logger } from "../infrastructure/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a one-click unsubscribe URL from a stored email's headers per
|
||||||
|
* RFC 8058. Returns the first `https:` URL in `List-Unsubscribe` only when
|
||||||
|
* `List-Unsubscribe-Post: List-Unsubscribe=One-Click` is also present — that
|
||||||
|
* Post header is what authorises an unattended one-click POST. `mailto:` and
|
||||||
|
* plaintext `http:` links are ignored (Workers cannot send SMTP and we never
|
||||||
|
* unsubscribe over plaintext). Header keys are matched case-insensitively;
|
||||||
|
* `EmailData.headers` already lowercases them, but we don't rely on it.
|
||||||
|
*/
|
||||||
|
export function parseOneClickUnsubscribe(
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): string | null {
|
||||||
|
let listUnsubscribe = "";
|
||||||
|
let post = "";
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
const k = key.toLowerCase();
|
||||||
|
if (k === "list-unsubscribe") listUnsubscribe = value;
|
||||||
|
else if (k === "list-unsubscribe-post") post = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.trim().toLowerCase() !== "list-unsubscribe=one-click") return null;
|
||||||
|
|
||||||
|
const matches = listUnsubscribe.match(/<([^>]+)>/g);
|
||||||
|
if (!matches) return null;
|
||||||
|
for (const token of matches) {
|
||||||
|
const url = token.slice(1, -1).trim();
|
||||||
|
if (/^https:\/\//i.test(url)) return url;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a single RFC 8058 one-click unsubscribe POST. Returns whether the
|
||||||
|
* endpoint accepted it. Never throws: network/timeout errors are logged and
|
||||||
|
* reported as a failure so callers can keep going.
|
||||||
|
*/
|
||||||
|
export async function sendOneClickUnsubscribe(url: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
redirect: "follow",
|
||||||
|
signal: AbortSignal.timeout(UNSUBSCRIBE_TIMEOUT_MS),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"User-Agent": "kill-the-news/1.0",
|
||||||
|
},
|
||||||
|
body: "List-Unsubscribe=One-Click",
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("One-click unsubscribe failed", { url, error: String(error) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send one-click unsubscribe requests for a batch of URLs (de-duplicated) and
|
||||||
|
* record the number that succeeded in the `unsubscribes_sent` counter. Never
|
||||||
|
* throws — intended to run in the background via ctx.waitUntil on feed deletion.
|
||||||
|
*/
|
||||||
|
export async function sendUnsubscribes(
|
||||||
|
urls: string[],
|
||||||
|
env: Env,
|
||||||
|
): Promise<void> {
|
||||||
|
const unique = Array.from(new Set(urls.filter(Boolean)));
|
||||||
|
if (unique.length === 0) return;
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
unique.map((url) => sendOneClickUnsubscribe(url)),
|
||||||
|
);
|
||||||
|
const succeeded = results.filter(
|
||||||
|
(r) => r.status === "fulfilled" && r.value,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (succeeded > 0) {
|
||||||
|
await bumpCounters(env.EMAIL_STORAGE, { unsubscribes_sent: succeeded });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createMockEnv } from "../test/setup";
|
||||||
|
import { WebSubSubscriptionRepository } from "./websub-subscription-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import type { Env, WebSubSubscription } from "../types";
|
||||||
|
|
||||||
|
const mockEnv = () => createMockEnv() as unknown as Env;
|
||||||
|
const fid = FeedId.unchecked("a.b.42");
|
||||||
|
|
||||||
|
describe("WebSubSubscriptionRepository", () => {
|
||||||
|
it("round-trips subscriptions and counts feeds with subscribers", async () => {
|
||||||
|
const repo = new WebSubSubscriptionRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
expect(await repo.get(fid)).toEqual([]);
|
||||||
|
const subs: WebSubSubscription[] = [
|
||||||
|
{ callbackUrl: "https://r.example/cb", expiresAt: 9999 },
|
||||||
|
];
|
||||||
|
await repo.save(fid, subs);
|
||||||
|
expect(await repo.get(fid)).toEqual(subs);
|
||||||
|
expect(await repo.countKeys()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Env, WebSubSubscription } from "../types";
|
||||||
|
import { feedKeys } from "../domain/feed-keys";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KV access for per-feed WebSub subscriber lists (`websub:subs:<feedId>`).
|
||||||
|
*/
|
||||||
|
export class WebSubSubscriptionRepository {
|
||||||
|
constructor(private readonly kv: KVNamespace) {}
|
||||||
|
|
||||||
|
static from(env: Env): WebSubSubscriptionRepository {
|
||||||
|
return new WebSubSubscriptionRepository(env.EMAIL_STORAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(feedId: FeedId): Promise<WebSubSubscription[]> {
|
||||||
|
const raw = await this.kv.get(feedKeys.websub(feedId.value), "json");
|
||||||
|
return (raw as WebSubSubscription[] | null) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(
|
||||||
|
feedId: FeedId,
|
||||||
|
subscriptions: WebSubSubscription[],
|
||||||
|
): Promise<void> {
|
||||||
|
await this.kv.put(
|
||||||
|
feedKeys.websub(feedId.value),
|
||||||
|
JSON.stringify(subscriptions),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of feeds that currently hold at least one WebSub subscription. */
|
||||||
|
async countKeys(): Promise<number> {
|
||||||
|
const prefix = feedKeys.websubPrefix();
|
||||||
|
let total = 0;
|
||||||
|
let cursor: string | undefined;
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const listed = await this.kv.list({ prefix, cursor, limit: 1000 });
|
||||||
|
total += listed.keys.length;
|
||||||
|
cursor = listed.list_complete ? undefined : listed.cursor;
|
||||||
|
} while (cursor);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error counting subscription keys", {
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ import {
|
|||||||
notifySubscribers,
|
notifySubscribers,
|
||||||
verifyAndStoreSubscription,
|
verifyAndStoreSubscription,
|
||||||
verifyAndDeleteSubscription,
|
verifyAndDeleteSubscription,
|
||||||
subscriptionKey,
|
|
||||||
} from "./websub";
|
} from "./websub";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
import type { Env, WebSubSubscription } from "../types";
|
import type { Env, WebSubSubscription } from "../types";
|
||||||
|
|
||||||
const mockEnv = () => createMockEnv() as unknown as Env;
|
const mockEnv = () => createMockEnv() as unknown as Env;
|
||||||
|
const fid = (value: string) => FeedId.unchecked(value);
|
||||||
|
|
||||||
describe("buildHmacSignature", () => {
|
describe("buildHmacSignature", () => {
|
||||||
it("returns sha256= prefixed hex", async () => {
|
it("returns sha256= prefixed hex", async () => {
|
||||||
@@ -36,7 +37,7 @@ describe("buildHmacSignature", () => {
|
|||||||
describe("getSubscriptions / saveSubscriptions", () => {
|
describe("getSubscriptions / saveSubscriptions", () => {
|
||||||
it("returns empty array when no subs exist", async () => {
|
it("returns empty array when no subs exist", async () => {
|
||||||
const env = mockEnv();
|
const env = mockEnv();
|
||||||
expect(await getSubscriptions("feed1", env)).toEqual([]);
|
expect(await getSubscriptions(fid("feed1"), env)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips stored subscriptions", async () => {
|
it("round-trips stored subscriptions", async () => {
|
||||||
@@ -47,12 +48,16 @@ describe("getSubscriptions / saveSubscriptions", () => {
|
|||||||
expiresAt: Date.now() + 60000,
|
expiresAt: Date.now() + 60000,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await saveSubscriptions("feed1", subs, env);
|
await saveSubscriptions(fid("feed1"), subs, env);
|
||||||
expect(await getSubscriptions("feed1", env)).toEqual(subs);
|
expect(await getSubscriptions(fid("feed1"), env)).toEqual(subs);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the correct KV key", () => {
|
it("uses the correct KV key", async () => {
|
||||||
expect(subscriptionKey("abc")).toBe("websub:subs:abc");
|
const env = mockEnv();
|
||||||
|
await saveSubscriptions(fid("abc"), [], env);
|
||||||
|
expect(
|
||||||
|
await env.EMAIL_STORAGE.get("websub:subs:abc", { type: "json" }),
|
||||||
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +71,7 @@ describe("notifySubscribers", () => {
|
|||||||
return HttpResponse.text("ok");
|
return HttpResponse.text("ok");
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await notifySubscribers("feed1", env);
|
await notifySubscribers(fid("feed1"), env);
|
||||||
expect(called).toBe(false);
|
expect(called).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,7 +83,7 @@ describe("notifySubscribers", () => {
|
|||||||
expiresAt: Date.now() + 60000,
|
expiresAt: Date.now() + 60000,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await saveSubscriptions("feed1", subs, env);
|
await saveSubscriptions(fid("feed1"), subs, env);
|
||||||
let called = false;
|
let called = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.post("https://reader.example/callback", () => {
|
http.post("https://reader.example/callback", () => {
|
||||||
@@ -86,7 +91,7 @@ describe("notifySubscribers", () => {
|
|||||||
return HttpResponse.text("ok");
|
return HttpResponse.text("ok");
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await notifySubscribers("feed1", env);
|
await notifySubscribers(fid("feed1"), env);
|
||||||
expect(called).toBe(false);
|
expect(called).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +115,7 @@ describe("notifySubscribers", () => {
|
|||||||
expiresAt: Date.now() + 60000,
|
expiresAt: Date.now() + 60000,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await saveSubscriptions("feed1", subs, env);
|
await saveSubscriptions(fid("feed1"), subs, env);
|
||||||
|
|
||||||
let receivedBody = "";
|
let receivedBody = "";
|
||||||
let receivedContentType = "";
|
let receivedContentType = "";
|
||||||
@@ -122,7 +127,7 @@ describe("notifySubscribers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await notifySubscribers("feed1", env);
|
await notifySubscribers(fid("feed1"), env);
|
||||||
|
|
||||||
expect(receivedBody).toContain("<?xml");
|
expect(receivedBody).toContain("<?xml");
|
||||||
expect(receivedContentType).toContain("application/rss+xml");
|
expect(receivedContentType).toContain("application/rss+xml");
|
||||||
@@ -149,7 +154,7 @@ describe("notifySubscribers", () => {
|
|||||||
secret: "mysecret",
|
secret: "mysecret",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await saveSubscriptions("feed1", subs, env);
|
await saveSubscriptions(fid("feed1"), subs, env);
|
||||||
|
|
||||||
let receivedSig256 = "";
|
let receivedSig256 = "";
|
||||||
let receivedSig = "";
|
let receivedSig = "";
|
||||||
@@ -161,7 +166,7 @@ describe("notifySubscribers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await notifySubscribers("feed1", env);
|
await notifySubscribers(fid("feed1"), env);
|
||||||
expect(receivedSig256).toMatch(/^sha256=[0-9a-f]{64}$/);
|
expect(receivedSig256).toMatch(/^sha256=[0-9a-f]{64}$/);
|
||||||
expect(receivedSig).toBe(""); // legacy header should NOT be sent
|
expect(receivedSig).toBe(""); // legacy header should NOT be sent
|
||||||
});
|
});
|
||||||
@@ -187,7 +192,7 @@ describe("notifySubscribers", () => {
|
|||||||
format: "atom",
|
format: "atom",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await saveSubscriptions("feed1", subs, env);
|
await saveSubscriptions(fid("feed1"), subs, env);
|
||||||
|
|
||||||
let receivedContentType = "";
|
let receivedContentType = "";
|
||||||
let receivedLink = "";
|
let receivedLink = "";
|
||||||
@@ -199,7 +204,7 @@ describe("notifySubscribers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await notifySubscribers("feed1", env);
|
await notifySubscribers(fid("feed1"), env);
|
||||||
expect(receivedContentType).toContain("application/atom+xml");
|
expect(receivedContentType).toContain("application/atom+xml");
|
||||||
expect(receivedLink).toContain(`/atom/feed1`);
|
expect(receivedLink).toContain(`/atom/feed1`);
|
||||||
expect(receivedLink).toContain(`rel="self"`);
|
expect(receivedLink).toContain(`rel="self"`);
|
||||||
@@ -231,7 +236,7 @@ describe("notifySubscribers", () => {
|
|||||||
format: "atom",
|
format: "atom",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await saveSubscriptions("feed1", subs, env);
|
await saveSubscriptions(fid("feed1"), subs, env);
|
||||||
|
|
||||||
const received: Record<string, string> = {};
|
const received: Record<string, string> = {};
|
||||||
server.use(
|
server.use(
|
||||||
@@ -245,7 +250,7 @@ describe("notifySubscribers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await notifySubscribers("feed1", env);
|
await notifySubscribers(fid("feed1"), env);
|
||||||
expect(received.rss).toContain("application/rss+xml");
|
expect(received.rss).toContain("application/rss+xml");
|
||||||
expect(received.atom).toContain("application/atom+xml");
|
expect(received.atom).toContain("application/atom+xml");
|
||||||
});
|
});
|
||||||
@@ -274,7 +279,7 @@ describe("notifySubscribers", () => {
|
|||||||
expiresAt: Date.now() + 60000,
|
expiresAt: Date.now() + 60000,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await saveSubscriptions("feed1", subs, env);
|
await saveSubscriptions(fid("feed1"), subs, env);
|
||||||
|
|
||||||
const notified: string[] = [];
|
const notified: string[] = [];
|
||||||
server.use(
|
server.use(
|
||||||
@@ -288,10 +293,10 @@ describe("notifySubscribers", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await notifySubscribers("feed1", env);
|
await notifySubscribers(fid("feed1"), env);
|
||||||
expect(notified).toEqual(["active"]);
|
expect(notified).toEqual(["active"]);
|
||||||
|
|
||||||
const remaining = await getSubscriptions("feed1", env);
|
const remaining = await getSubscriptions(fid("feed1"), env);
|
||||||
expect(remaining).toHaveLength(1);
|
expect(remaining).toHaveLength(1);
|
||||||
expect(remaining[0].callbackUrl).toBe("https://active.example/callback");
|
expect(remaining[0].callbackUrl).toBe("https://active.example/callback");
|
||||||
});
|
});
|
||||||
@@ -309,7 +314,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await verifyAndStoreSubscription(
|
const result = await verifyAndStoreSubscription(
|
||||||
"feed1",
|
fid("feed1"),
|
||||||
"https://reader.example/callback",
|
"https://reader.example/callback",
|
||||||
undefined,
|
undefined,
|
||||||
86400,
|
86400,
|
||||||
@@ -318,7 +323,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
const subs = await getSubscriptions("feed1", env);
|
const subs = await getSubscriptions(fid("feed1"), env);
|
||||||
expect(subs).toHaveLength(1);
|
expect(subs).toHaveLength(1);
|
||||||
expect(subs[0].callbackUrl).toBe("https://reader.example/callback");
|
expect(subs[0].callbackUrl).toBe("https://reader.example/callback");
|
||||||
expect(subs[0].expiresAt).toBeGreaterThan(Date.now());
|
expect(subs[0].expiresAt).toBeGreaterThan(Date.now());
|
||||||
@@ -337,7 +342,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await verifyAndStoreSubscription(
|
const result = await verifyAndStoreSubscription(
|
||||||
"feed1",
|
fid("feed1"),
|
||||||
"https://reader.example/callback",
|
"https://reader.example/callback",
|
||||||
undefined,
|
undefined,
|
||||||
86400,
|
86400,
|
||||||
@@ -347,7 +352,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(receivedTopic).toContain("/atom/feed1");
|
expect(receivedTopic).toContain("/atom/feed1");
|
||||||
const subs = await getSubscriptions("feed1", env);
|
const subs = await getSubscriptions(fid("feed1"), env);
|
||||||
expect(subs[0].format).toBe("atom");
|
expect(subs[0].format).toBe("atom");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -360,7 +365,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await verifyAndStoreSubscription(
|
const result = await verifyAndStoreSubscription(
|
||||||
"feed1",
|
fid("feed1"),
|
||||||
"https://reader.example/callback",
|
"https://reader.example/callback",
|
||||||
undefined,
|
undefined,
|
||||||
86400,
|
86400,
|
||||||
@@ -369,7 +374,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
const subs = await getSubscriptions("feed1", env);
|
const subs = await getSubscriptions(fid("feed1"), env);
|
||||||
expect(subs).toHaveLength(0);
|
expect(subs).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -378,7 +383,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
const existing: WebSubSubscription[] = [
|
const existing: WebSubSubscription[] = [
|
||||||
{ callbackUrl: "https://reader.example/callback", expiresAt: 1000 },
|
{ callbackUrl: "https://reader.example/callback", expiresAt: 1000 },
|
||||||
];
|
];
|
||||||
await saveSubscriptions("feed1", existing, env);
|
await saveSubscriptions(fid("feed1"), existing, env);
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get("https://reader.example/callback", ({ request }) => {
|
http.get("https://reader.example/callback", ({ request }) => {
|
||||||
@@ -389,7 +394,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await verifyAndStoreSubscription(
|
const result = await verifyAndStoreSubscription(
|
||||||
"feed1",
|
fid("feed1"),
|
||||||
"https://reader.example/callback",
|
"https://reader.example/callback",
|
||||||
"newsecret",
|
"newsecret",
|
||||||
3600,
|
3600,
|
||||||
@@ -398,7 +403,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
const subs = await getSubscriptions("feed1", env);
|
const subs = await getSubscriptions(fid("feed1"), env);
|
||||||
expect(subs).toHaveLength(1);
|
expect(subs).toHaveLength(1);
|
||||||
expect(subs[0].secret).toBe("newsecret");
|
expect(subs[0].secret).toBe("newsecret");
|
||||||
});
|
});
|
||||||
@@ -410,7 +415,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await verifyAndStoreSubscription(
|
const result = await verifyAndStoreSubscription(
|
||||||
"feed1",
|
fid("feed1"),
|
||||||
"https://reader.example/callback",
|
"https://reader.example/callback",
|
||||||
undefined,
|
undefined,
|
||||||
86400,
|
86400,
|
||||||
@@ -419,7 +424,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
const subs = await getSubscriptions("feed1", env);
|
const subs = await getSubscriptions(fid("feed1"), env);
|
||||||
expect(subs).toHaveLength(0);
|
expect(subs).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -434,7 +439,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await verifyAndStoreSubscription(
|
const result = await verifyAndStoreSubscription(
|
||||||
"feed1",
|
fid("feed1"),
|
||||||
"https://reader.example/callback",
|
"https://reader.example/callback",
|
||||||
undefined,
|
undefined,
|
||||||
86400,
|
86400,
|
||||||
@@ -443,7 +448,7 @@ describe("verifyAndStoreSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
const subs = await getSubscriptions("feed1", env);
|
const subs = await getSubscriptions(fid("feed1"), env);
|
||||||
expect(subs).toHaveLength(0);
|
expect(subs).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -452,7 +457,7 @@ describe("verifyAndDeleteSubscription", () => {
|
|||||||
it("removes subscription and returns true when callback echoes challenge", async () => {
|
it("removes subscription and returns true when callback echoes challenge", async () => {
|
||||||
const env = mockEnv();
|
const env = mockEnv();
|
||||||
await saveSubscriptions(
|
await saveSubscriptions(
|
||||||
"feed1",
|
fid("feed1"),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
callbackUrl: "https://reader.example/callback",
|
callbackUrl: "https://reader.example/callback",
|
||||||
@@ -471,19 +476,19 @@ describe("verifyAndDeleteSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await verifyAndDeleteSubscription(
|
const result = await verifyAndDeleteSubscription(
|
||||||
"feed1",
|
fid("feed1"),
|
||||||
"https://reader.example/callback",
|
"https://reader.example/callback",
|
||||||
env,
|
env,
|
||||||
);
|
);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
const subs = await getSubscriptions("feed1", env);
|
const subs = await getSubscriptions(fid("feed1"), env);
|
||||||
expect(subs).toHaveLength(0);
|
expect(subs).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false and leaves subscription intact when callback returns wrong challenge", async () => {
|
it("returns false and leaves subscription intact when callback returns wrong challenge", async () => {
|
||||||
const env = mockEnv();
|
const env = mockEnv();
|
||||||
await saveSubscriptions(
|
await saveSubscriptions(
|
||||||
"feed1",
|
fid("feed1"),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
callbackUrl: "https://reader.example/callback",
|
callbackUrl: "https://reader.example/callback",
|
||||||
@@ -500,19 +505,19 @@ describe("verifyAndDeleteSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await verifyAndDeleteSubscription(
|
const result = await verifyAndDeleteSubscription(
|
||||||
"feed1",
|
fid("feed1"),
|
||||||
"https://reader.example/callback",
|
"https://reader.example/callback",
|
||||||
env,
|
env,
|
||||||
);
|
);
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
const subs = await getSubscriptions("feed1", env);
|
const subs = await getSubscriptions(fid("feed1"), env);
|
||||||
expect(subs).toHaveLength(1);
|
expect(subs).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false and leaves subscription intact when callback fetch fails", async () => {
|
it("returns false and leaves subscription intact when callback fetch fails", async () => {
|
||||||
const env = mockEnv();
|
const env = mockEnv();
|
||||||
await saveSubscriptions(
|
await saveSubscriptions(
|
||||||
"feed1",
|
fid("feed1"),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
callbackUrl: "https://reader.example/callback",
|
callbackUrl: "https://reader.example/callback",
|
||||||
@@ -527,12 +532,12 @@ describe("verifyAndDeleteSubscription", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const result = await verifyAndDeleteSubscription(
|
const result = await verifyAndDeleteSubscription(
|
||||||
"feed1",
|
fid("feed1"),
|
||||||
"https://reader.example/callback",
|
"https://reader.example/callback",
|
||||||
env,
|
env,
|
||||||
);
|
);
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
const subs = await getSubscriptions("feed1", env);
|
const subs = await getSubscriptions(fid("feed1"), env);
|
||||||
expect(subs).toHaveLength(1);
|
expect(subs).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,36 +1,23 @@
|
|||||||
import {
|
import { Env, FeedConfig, EmailData, WebSubSubscription } from "../types";
|
||||||
Env,
|
|
||||||
FeedConfig,
|
|
||||||
FeedMetadata,
|
|
||||||
EmailData,
|
|
||||||
WebSubSubscription,
|
|
||||||
} from "../types";
|
|
||||||
import { generateRssFeed, generateAtomFeed } from "./feed-generator";
|
import { generateRssFeed, generateAtomFeed } from "./feed-generator";
|
||||||
import { baseUrl, feedRssUrl, feedAtomUrl, feedUrl } from "./urls";
|
import { baseUrl, feedRssUrl, feedAtomUrl, feedUrl } from "./urls";
|
||||||
|
import { FeedRepository } from "./feed-repository";
|
||||||
const KV_PREFIX = "websub:subs:";
|
import { WebSubSubscriptionRepository } from "./websub-subscription-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
export function subscriptionKey(feedId: string): string {
|
|
||||||
return `${KV_PREFIX}${feedId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSubscriptions(
|
export async function getSubscriptions(
|
||||||
feedId: string,
|
feedId: FeedId,
|
||||||
env: Env,
|
env: Env,
|
||||||
): Promise<WebSubSubscription[]> {
|
): Promise<WebSubSubscription[]> {
|
||||||
const raw = await env.EMAIL_STORAGE.get(subscriptionKey(feedId), "json");
|
return WebSubSubscriptionRepository.from(env).get(feedId);
|
||||||
return (raw as WebSubSubscription[] | null) ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSubscriptions(
|
export async function saveSubscriptions(
|
||||||
feedId: string,
|
feedId: FeedId,
|
||||||
subscriptions: WebSubSubscription[],
|
subscriptions: WebSubSubscription[],
|
||||||
env: Env,
|
env: Env,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await env.EMAIL_STORAGE.put(
|
await WebSubSubscriptionRepository.from(env).save(feedId, subscriptions);
|
||||||
subscriptionKey(feedId),
|
|
||||||
JSON.stringify(subscriptions),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildHmacSignature(
|
export async function buildHmacSignature(
|
||||||
@@ -56,21 +43,21 @@ export async function buildHmacSignature(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildFeedXml(
|
async function buildFeedXml(
|
||||||
feedId: string,
|
feedId: FeedId,
|
||||||
env: Env,
|
env: Env,
|
||||||
format: "rss" | "atom" = "rss",
|
format: "rss" | "atom" = "rss",
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const [rawMetadata, rawConfig] = await Promise.all([
|
const repo = FeedRepository.from(env);
|
||||||
env.EMAIL_STORAGE.get(`feed:${feedId}:metadata`, "json"),
|
const [feedMetadata, rawConfig] = await Promise.all([
|
||||||
env.EMAIL_STORAGE.get(`feed:${feedId}:config`, "json"),
|
repo.getMetadata(feedId),
|
||||||
|
repo.getConfig(feedId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const feedMetadata = rawMetadata as FeedMetadata | null;
|
|
||||||
if (!feedMetadata) return null;
|
if (!feedMetadata) return null;
|
||||||
|
|
||||||
const base = baseUrl(env);
|
const base = baseUrl(env);
|
||||||
const feedConfig = (rawConfig as FeedConfig | null) ?? {
|
const feedConfig: FeedConfig = rawConfig ?? {
|
||||||
title: `Newsletter Feed ${feedId}`,
|
title: `Newsletter Feed ${feedId.value}`,
|
||||||
description: "Converted email newsletter",
|
description: "Converted email newsletter",
|
||||||
language: "en",
|
language: "en",
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
@@ -78,12 +65,7 @@ async function buildFeedXml(
|
|||||||
|
|
||||||
const emails = feedMetadata.emails.slice(0, 20);
|
const emails = feedMetadata.emails.slice(0, 20);
|
||||||
const emailsData = (
|
const emailsData = (
|
||||||
await Promise.all(
|
await Promise.all(emails.map((m) => repo.getEmail(m.key)))
|
||||||
emails.map(
|
|
||||||
(m) =>
|
|
||||||
env.EMAIL_STORAGE.get(m.key, "json") as Promise<EmailData | null>,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).filter((d): d is EmailData => d !== null);
|
).filter((d): d is EmailData => d !== null);
|
||||||
|
|
||||||
if (format === "atom") {
|
if (format === "atom") {
|
||||||
@@ -91,15 +73,15 @@ async function buildFeedXml(
|
|||||||
feedConfig,
|
feedConfig,
|
||||||
emailsData,
|
emailsData,
|
||||||
base,
|
base,
|
||||||
feedId,
|
feedId.value,
|
||||||
feedAtomUrl(feedId, env),
|
feedAtomUrl(feedId.value, env),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return generateRssFeed(feedConfig, emailsData, base, feedId);
|
return generateRssFeed(feedConfig, emailsData, base, feedId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function notifySubscribers(
|
export async function notifySubscribers(
|
||||||
feedId: string,
|
feedId: FeedId,
|
||||||
env: Env,
|
env: Env,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const subs = await getSubscriptions(feedId, env);
|
const subs = await getSubscriptions(feedId, env);
|
||||||
@@ -157,12 +139,17 @@ export async function notifySubscribers(
|
|||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
...(rssFeed
|
...(rssFeed
|
||||||
? rssSubs.map((sub) =>
|
? rssSubs.map((sub) =>
|
||||||
deliver(sub, rssFeed, "application/rss+xml", `/rss/${feedId}`),
|
deliver(sub, rssFeed, "application/rss+xml", `/rss/${feedId.value}`),
|
||||||
)
|
)
|
||||||
: []),
|
: []),
|
||||||
...(atomFeed
|
...(atomFeed
|
||||||
? atomSubs.map((sub) =>
|
? atomSubs.map((sub) =>
|
||||||
deliver(sub, atomFeed, "application/atom+xml", `/atom/${feedId}`),
|
deliver(
|
||||||
|
sub,
|
||||||
|
atomFeed,
|
||||||
|
"application/atom+xml",
|
||||||
|
`/atom/${feedId.value}`,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: []),
|
: []),
|
||||||
]);
|
]);
|
||||||
@@ -193,7 +180,7 @@ async function verifyCallback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyAndStoreSubscription(
|
export async function verifyAndStoreSubscription(
|
||||||
feedId: string,
|
feedId: FeedId,
|
||||||
callbackUrl: string,
|
callbackUrl: string,
|
||||||
secret: string | undefined,
|
secret: string | undefined,
|
||||||
leaseSeconds: number,
|
leaseSeconds: number,
|
||||||
@@ -202,7 +189,7 @@ export async function verifyAndStoreSubscription(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const verified = await verifyCallback(callbackUrl, {
|
const verified = await verifyCallback(callbackUrl, {
|
||||||
"hub.mode": "subscribe",
|
"hub.mode": "subscribe",
|
||||||
"hub.topic": feedUrl(format, feedId, env),
|
"hub.topic": feedUrl(format, feedId.value, env),
|
||||||
"hub.lease_seconds": String(leaseSeconds),
|
"hub.lease_seconds": String(leaseSeconds),
|
||||||
});
|
});
|
||||||
if (!verified) return false;
|
if (!verified) return false;
|
||||||
@@ -225,13 +212,13 @@ export async function verifyAndStoreSubscription(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyAndDeleteSubscription(
|
export async function verifyAndDeleteSubscription(
|
||||||
feedId: string,
|
feedId: FeedId,
|
||||||
callbackUrl: string,
|
callbackUrl: string,
|
||||||
env: Env,
|
env: Env,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const verified = await verifyCallback(callbackUrl, {
|
const verified = await verifyCallback(callbackUrl, {
|
||||||
"hub.mode": "unsubscribe",
|
"hub.mode": "unsubscribe",
|
||||||
"hub.topic": feedRssUrl(feedId, env),
|
"hub.topic": feedRssUrl(feedId.value, env),
|
||||||
});
|
});
|
||||||
if (!verified) return false;
|
if (!verified) return false;
|
||||||
|
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a fire-and-forget background task. The HTTP edge adapts this over
|
||||||
|
* `ctx.waitUntil`; the application/domain layers depend on this plain function
|
||||||
|
* type instead of Hono's `Context`.
|
||||||
|
*/
|
||||||
|
export type BackgroundScheduler = (task: Promise<unknown>) => void;
|
||||||
|
|
||||||
/** Calls ctx.waitUntil() without throwing when the ExecutionContext is absent (e.g. Node tests). */
|
/** Calls ctx.waitUntil() without throwing when the ExecutionContext is absent (e.g. Node tests). */
|
||||||
export function waitUntilSafe(c: Context, promise: Promise<unknown>): void {
|
export function waitUntilSafe(c: Context, promise: Promise<unknown>): void {
|
||||||
try {
|
try {
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
+352
-2
@@ -1,7 +1,9 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import app from "./admin";
|
import app from "./admin";
|
||||||
import { createMockEnv } from "../test/setup";
|
import { createMockEnv, server } from "../test/setup";
|
||||||
|
import { getCounters } from "../application/stats";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
|
|
||||||
describe("Admin Routes", () => {
|
describe("Admin Routes", () => {
|
||||||
@@ -146,7 +148,7 @@ describe("Admin Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(302); // Redirects back to dashboard
|
expect(res.status).toBe(302); // Redirects back to dashboard
|
||||||
expect(res.headers.get("Location")).toBe("/admin?view=list");
|
expect(res.headers.get("Location")).toBe("/admin?view=list#your-feeds");
|
||||||
|
|
||||||
// Verify feed was created in KV
|
// Verify feed was created in KV
|
||||||
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
||||||
@@ -328,6 +330,119 @@ describe("Admin Routes", () => {
|
|||||||
expect(payload.feedId).toBe(feedId);
|
expect(payload.feedId).toBe(feedId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fires one-click unsubscribe requests on feed deletion and bumps the counter", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("title", "Unsub Feed");
|
||||||
|
|
||||||
|
await request("/admin/feeds/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
||||||
|
"feeds:list",
|
||||||
|
"json",
|
||||||
|
)) as {
|
||||||
|
feeds: Array<{ id: string }>;
|
||||||
|
} | null;
|
||||||
|
const feedId = feedList?.feeds[0].id as string;
|
||||||
|
|
||||||
|
// Simulate an ingested email having captured an unsubscribe URL.
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${feedId}:metadata`,
|
||||||
|
JSON.stringify({
|
||||||
|
emails: [],
|
||||||
|
unsubscribe: { "news@example.com": "https://example.com/u/1" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let unsubHit = false;
|
||||||
|
server.use(
|
||||||
|
http.post("https://example.com/u/1", () => {
|
||||||
|
unsubHit = true;
|
||||||
|
return HttpResponse.text("ok");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pending: Promise<unknown>[] = [];
|
||||||
|
const ctx = {
|
||||||
|
waitUntil: (p: Promise<unknown>) => pending.push(p),
|
||||||
|
passThroughOnException: () => {},
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
const deleteRes = await testApp.request(
|
||||||
|
`/admin/feeds/${feedId}/delete`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mockEnv,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
expect(deleteRes.status).toBe(302);
|
||||||
|
|
||||||
|
await Promise.all(pending);
|
||||||
|
|
||||||
|
expect(unsubHit).toBe(true);
|
||||||
|
const counters = await getCounters(mockEnv.EMAIL_STORAGE);
|
||||||
|
expect(counters.unsubscribes_sent).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends no unsubscribe requests when the feed has none", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("title", "No Unsub Feed");
|
||||||
|
|
||||||
|
await request("/admin/feeds/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
||||||
|
"feeds:list",
|
||||||
|
"json",
|
||||||
|
)) as {
|
||||||
|
feeds: Array<{ id: string }>;
|
||||||
|
} | null;
|
||||||
|
const feedId = feedList?.feeds[0].id as string;
|
||||||
|
|
||||||
|
const pending: Promise<unknown>[] = [];
|
||||||
|
const ctx = {
|
||||||
|
waitUntil: (p: Promise<unknown>) => pending.push(p),
|
||||||
|
passThroughOnException: () => {},
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
await testApp.request(
|
||||||
|
`/admin/feeds/${feedId}/delete`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mockEnv,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(pending);
|
||||||
|
|
||||||
|
const counters = await getCounters(mockEnv.EMAIL_STORAGE);
|
||||||
|
expect(counters.unsubscribes_sent).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("should allow bulk feed deletion with valid authentication", async () => {
|
it("should allow bulk feed deletion with valid authentication", async () => {
|
||||||
const authCookie = await loginAndGetCookie();
|
const authCookie = await loginAndGetCookie();
|
||||||
|
|
||||||
@@ -561,6 +676,241 @@ describe("Admin Routes", () => {
|
|||||||
} | null;
|
} | null;
|
||||||
expect(metadataAfter?.emails.length).toBe(0);
|
expect(metadataAfter?.emails.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should show a paperclip indicator only for emails with attachments", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("title", "Email Feed");
|
||||||
|
|
||||||
|
const createRes = await request("/admin/feeds/create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
expect(createRes.status).toBe(302);
|
||||||
|
|
||||||
|
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
||||||
|
"feeds:list",
|
||||||
|
"json",
|
||||||
|
)) as { feeds: Array<{ id: string; title: string }> } | null;
|
||||||
|
const feedId = feedList?.feeds[0].id as string;
|
||||||
|
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${feedId}:metadata`,
|
||||||
|
JSON.stringify({
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
key: `feed:${feedId}:1`,
|
||||||
|
subject: "With attachments",
|
||||||
|
receivedAt: 2,
|
||||||
|
attachmentIds: ["att-1", "att-2"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `feed:${feedId}:2`,
|
||||||
|
subject: "No attachments",
|
||||||
|
receivedAt: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(`/admin/feeds/${feedId}/emails`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
|
||||||
|
expect(body).toContain("2 attachments");
|
||||||
|
const indicatorCount = (body.match(/attachment-indicator/g) || [])
|
||||||
|
.length;
|
||||||
|
expect(indicatorCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists attachments with download links on the email detail page", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const feedId = "detail-feed";
|
||||||
|
const emailKey = `feed:${feedId}:1`;
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
emailKey,
|
||||||
|
JSON.stringify({
|
||||||
|
subject: "With attachments",
|
||||||
|
from: "sender@example.com",
|
||||||
|
content: "<p>hello</p>",
|
||||||
|
receivedAt: 1,
|
||||||
|
headers: {},
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: "att-123",
|
||||||
|
filename: "report final.pdf",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
size: 2048,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(`/admin/emails/${emailKey}`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
|
||||||
|
expect(body).toContain("Attachments");
|
||||||
|
expect(body).toContain(
|
||||||
|
`/files/att-123/${encodeURIComponent("report final.pdf")}`,
|
||||||
|
);
|
||||||
|
expect(body).toContain("report final.pdf");
|
||||||
|
expect(body).toContain("2.0 KB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders inline cid images in place and hides them from the attachments list", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const feedId = "detail-feed";
|
||||||
|
const emailKey = `feed:${feedId}:3`;
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
emailKey,
|
||||||
|
JSON.stringify({
|
||||||
|
subject: "With inline image",
|
||||||
|
from: "sender@example.com",
|
||||||
|
content: '<p>hello</p><img src="cid:logo123"/>',
|
||||||
|
receivedAt: 3,
|
||||||
|
headers: {},
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
id: "img-1",
|
||||||
|
filename: "logo.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
size: 512,
|
||||||
|
contentId: "logo123",
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(`/admin/emails/${emailKey}`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
|
||||||
|
// The rendered preview is a base64 data: iframe; decode and inspect it.
|
||||||
|
const match = body.match(/data:text\/html;base64,([A-Za-z0-9+/=]+)/);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
const decoded = Buffer.from(match![1], "base64").toString("utf-8");
|
||||||
|
// cid: is rewritten to an absolute /files URL so it resolves in the iframe.
|
||||||
|
expect(decoded).toContain(
|
||||||
|
"https://test.getmynews.app/files/img-1/logo.png",
|
||||||
|
);
|
||||||
|
expect(decoded).not.toContain("cid:logo123");
|
||||||
|
|
||||||
|
// Inline image is not surfaced as a downloadable attachment.
|
||||||
|
expect(body).not.toContain("Attachments");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render an attachments section when the email has none", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const feedId = "detail-feed";
|
||||||
|
const emailKey = `feed:${feedId}:2`;
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
emailKey,
|
||||||
|
JSON.stringify({
|
||||||
|
subject: "No attachments",
|
||||||
|
from: "sender@example.com",
|
||||||
|
content: "<p>hello</p>",
|
||||||
|
receivedAt: 2,
|
||||||
|
headers: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(`/admin/emails/${emailKey}`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
|
||||||
|
expect(body).not.toContain("Attachments");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("form-based bulk-delete also removes R2 attachments", async () => {
|
||||||
|
const r2Env = createMockEnv({ withR2: true }) as unknown as Env;
|
||||||
|
const bucket = r2Env.ATTACHMENT_BUCKET as unknown as {
|
||||||
|
put: (k: string, v: string) => Promise<void>;
|
||||||
|
_has: (k: string) => boolean;
|
||||||
|
};
|
||||||
|
await bucket.put("att-1", "data1");
|
||||||
|
await bucket.put("att-2", "data2");
|
||||||
|
|
||||||
|
const loginForm = new FormData();
|
||||||
|
loginForm.append("password", "test-password");
|
||||||
|
const loginRes = await testApp.request(
|
||||||
|
"/admin/login",
|
||||||
|
{ method: "POST", body: loginForm },
|
||||||
|
r2Env,
|
||||||
|
);
|
||||||
|
const authCookie = (loginRes.headers.get("Set-Cookie") as string).split(
|
||||||
|
";",
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const feedId = "bulk-r2-feed";
|
||||||
|
await r2Env.EMAIL_STORAGE.put(
|
||||||
|
"feeds:list",
|
||||||
|
JSON.stringify({ feeds: [{ id: feedId, title: "F" }] }),
|
||||||
|
);
|
||||||
|
await r2Env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${feedId}:config`,
|
||||||
|
JSON.stringify({ title: "F", language: "en", created_at: 1 }),
|
||||||
|
);
|
||||||
|
const emailKey = `feed:${feedId}:1`;
|
||||||
|
await r2Env.EMAIL_STORAGE.put(
|
||||||
|
emailKey,
|
||||||
|
JSON.stringify({
|
||||||
|
subject: "x",
|
||||||
|
from: "a@b.c",
|
||||||
|
content: "<p>x</p>",
|
||||||
|
receivedAt: 1,
|
||||||
|
headers: {},
|
||||||
|
attachments: [{ id: "att-1" }, { id: "att-2" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await r2Env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${feedId}:metadata`,
|
||||||
|
JSON.stringify({
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
key: emailKey,
|
||||||
|
subject: "x",
|
||||||
|
receivedAt: 1,
|
||||||
|
attachmentIds: ["att-1", "att-2"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("emailKeys", emailKey);
|
||||||
|
const res = await testApp.request(
|
||||||
|
`/admin/feeds/${feedId}/emails/bulk-delete`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
},
|
||||||
|
r2Env,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
expect(res.headers.get("Location")).toContain("bulkDeleted");
|
||||||
|
expect(await r2Env.EMAIL_STORAGE.get(emailKey, "json")).toBeNull();
|
||||||
|
expect(bucket._has("att-1")).toBe(false);
|
||||||
|
expect(bucket._has("att-2")).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+192
-81
@@ -2,13 +2,20 @@ import { Context, Hono } from "hono";
|
|||||||
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Env, FeedConfig } from "../types";
|
import { Env } from "../types";
|
||||||
import { csrf } from "hono/csrf";
|
import { csrf } from "hono/csrf";
|
||||||
import { ADMIN_COOKIE_MAX_AGE } from "../config/constants";
|
import { ADMIN_COOKIE_MAX_AGE } from "../config/constants";
|
||||||
import { logger } from "../lib/logger";
|
import { logger } from "../infrastructure/logger";
|
||||||
|
import { timingSafeEqual, checkProxyAuth } from "../infrastructure/auth";
|
||||||
import { Layout, clampText } from "./admin/ui";
|
import { Layout, clampText } from "./admin/ui";
|
||||||
import { listAllFeeds, updateFeedInList } from "./admin/helpers";
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../utils/urls";
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import { editFeedDetails } from "../application/feed-service";
|
||||||
|
import {
|
||||||
|
feedRssUrl,
|
||||||
|
feedAtomUrl,
|
||||||
|
feedEmailAddress,
|
||||||
|
} from "../infrastructure/urls";
|
||||||
import { feedsRouter } from "./admin/feeds";
|
import { feedsRouter } from "./admin/feeds";
|
||||||
import { emailsRouter } from "./admin/emails";
|
import { emailsRouter } from "./admin/emails";
|
||||||
import { dashboardScript } from "../scripts/generated/dashboard";
|
import { dashboardScript } from "../scripts/generated/dashboard";
|
||||||
@@ -37,27 +44,6 @@ app.use("*", async (c, next) => {
|
|||||||
await next();
|
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
|
// Authentication middleware for admin routes
|
||||||
async function authMiddleware(c: Context, next: () => Promise<void>) {
|
async function authMiddleware(c: Context, next: () => Promise<void>) {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
@@ -69,23 +55,9 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Proxy auth: only active when both env vars are present
|
// Proxy auth: only active when both env vars are present
|
||||||
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
|
if (checkProxyAuth(c, env)) {
|
||||||
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();
|
return next();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: signed cookie
|
// Fallback: signed cookie
|
||||||
const authCookie = await getSignedCookie(
|
const authCookie = await getSignedCookie(
|
||||||
@@ -145,7 +117,12 @@ app.get("/login", (c) => {
|
|||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<rect width="24" height="24" rx="12" fill="var(--color-primary)" />
|
<rect
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
rx="12"
|
||||||
|
fill="var(--color-primary)"
|
||||||
|
/>
|
||||||
<path
|
<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"
|
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="white"
|
||||||
@@ -161,9 +138,7 @@ app.get("/login", (c) => {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="auth-title">kill-the-news</h1>
|
<h1 class="auth-title">kill-the-news</h1>
|
||||||
{errorMessage && (
|
{errorMessage && <div class="auth-error">{errorMessage}</div>}
|
||||||
<div class="auth-error">{errorMessage}</div>
|
|
||||||
)}
|
|
||||||
<form class="auth-form" action="/admin/login" method="post">
|
<form class="auth-form" action="/admin/login" method="post">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
@@ -279,18 +254,46 @@ const CopyFieldInline = ({ value }: CopyFieldInlineProps) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function formatExpiry(expiresAt: number): { label: string; expired: boolean } {
|
||||||
|
const remaining = expiresAt - Date.now();
|
||||||
|
if (remaining <= 0) {
|
||||||
|
const h = Math.floor(-remaining / 3_600_000);
|
||||||
|
return {
|
||||||
|
label: h > 0 ? `Expired ${h}h ago` : "Just expired",
|
||||||
|
expired: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const h = Math.floor(remaining / 3_600_000);
|
||||||
|
if (h >= 48) {
|
||||||
|
return { label: `Expires in ${Math.floor(h / 24)}d`, expired: false };
|
||||||
|
}
|
||||||
|
const m = Math.floor((remaining % 3_600_000) / 60_000);
|
||||||
|
return {
|
||||||
|
label: h > 0 ? `Expires in ${h}h ${m}m` : `Expires in ${m}m`,
|
||||||
|
expired: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExpiryBadge = ({ expiresAt }: { expiresAt: number }) => {
|
||||||
|
const { label, expired } = formatExpiry(expiresAt);
|
||||||
|
return (
|
||||||
|
<span class={`pill ${expired ? "pill-expired" : "pill-expiry"}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Admin dashboard route
|
// Admin dashboard route
|
||||||
app.get("/", async (c) => {
|
app.get("/", async (c) => {
|
||||||
// Type assertion for environment variables
|
// Type assertion for environment variables
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
|
||||||
const url = new URL(c.req.url);
|
const url = new URL(c.req.url);
|
||||||
const view = url.searchParams.get("view") === "table" ? "table" : "list";
|
const view = url.searchParams.get("view") === "table" ? "table" : "list";
|
||||||
const message = url.searchParams.get("message");
|
const message = url.searchParams.get("message");
|
||||||
const count = Number(url.searchParams.get("count") || "0");
|
const count = Number(url.searchParams.get("count") || "0");
|
||||||
|
|
||||||
// List all feeds
|
// List all feeds
|
||||||
const feedList = await listAllFeeds(emailStorage);
|
const feedList = await FeedRepository.from(env).listFeeds();
|
||||||
|
|
||||||
// Keep the dashboard fast: avoid N KV reads for N feeds.
|
// Keep the dashboard fast: avoid N KV reads for N feeds.
|
||||||
// We store title/description in `feeds:list` (description is optional for older data).
|
// We store title/description in `feeds:list` (description is optional for older data).
|
||||||
@@ -345,14 +348,22 @@ app.get("/", async (c) => {
|
|||||||
<p>Manage your email newsletter feeds</p>
|
<p>Manage your email newsletter feeds</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<a href="/" class="button button-secondary">
|
||||||
|
Status
|
||||||
|
</a>
|
||||||
<a href="/admin/logout" class="button button-logout">
|
<a href="/admin/logout" class="button button-logout">
|
||||||
Logout
|
Logout
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<details
|
||||||
|
class="card create-feed-card"
|
||||||
|
open={feedsWithConfig.length === 0}
|
||||||
|
>
|
||||||
|
<summary class="create-feed-summary">
|
||||||
<h2>Create New Feed</h2>
|
<h2>Create New Feed</h2>
|
||||||
|
</summary>
|
||||||
<form action="/admin/feeds/create" method="post">
|
<form action="/admin/feeds/create" method="post">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title">Feed Title</label>
|
<label for="title">Feed Title</label>
|
||||||
@@ -396,6 +407,29 @@ app.get("/", async (c) => {
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lifetime_hours">
|
||||||
|
Lifetime (hours{env.FEED_TTL_HOURS ? "" : ", optional"})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="lifetime_hours"
|
||||||
|
name="lifetime_hours"
|
||||||
|
min="1"
|
||||||
|
value={env.FEED_TTL_HOURS || ""}
|
||||||
|
disabled={!!env.FEED_TTL_HOURS}
|
||||||
|
placeholder={env.FEED_TTL_HOURS ? undefined : "No expiry"}
|
||||||
|
/>
|
||||||
|
{env.FEED_TTL_HOURS ? (
|
||||||
|
<small>
|
||||||
|
Feed lifetime is fixed to {env.FEED_TTL_HOURS}h by server
|
||||||
|
configuration.
|
||||||
|
</small>
|
||||||
|
) : (
|
||||||
|
<small>Leave empty for no expiry.</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<input type="hidden" id="language" name="language" value="en" />
|
<input type="hidden" id="language" name="language" value="en" />
|
||||||
<input type="hidden" name="view" value={view} />
|
<input type="hidden" name="view" value={view} />
|
||||||
|
|
||||||
@@ -403,7 +437,7 @@ app.get("/", async (c) => {
|
|||||||
Create Feed
|
Create Feed
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
{message === "bulkDeleted" && (
|
{message === "bulkDeleted" && (
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -416,7 +450,7 @@ app.get("/", async (c) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar" id="your-feeds">
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<h2 style="margin: 0;">Your Feeds</h2>
|
<h2 style="margin: 0;">Your Feeds</h2>
|
||||||
<span class="pill" id="feed-total-count">
|
<span class="pill" id="feed-total-count">
|
||||||
@@ -489,6 +523,7 @@ app.get("/", async (c) => {
|
|||||||
<col data-col="email" style="width: 200px;" />
|
<col data-col="email" style="width: 200px;" />
|
||||||
<col data-col="rss" style="width: 190px;" />
|
<col data-col="rss" style="width: 190px;" />
|
||||||
<col data-col="atom" style="width: 190px;" />
|
<col data-col="atom" style="width: 190px;" />
|
||||||
|
<col data-col="expires" style="width: 130px;" />
|
||||||
<col data-col="actions" style="width: 170px;" />
|
<col data-col="actions" style="width: 170px;" />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -588,7 +623,11 @@ app.get("/", async (c) => {
|
|||||||
title="Resize"
|
title="Resize"
|
||||||
></div>
|
></div>
|
||||||
</th>
|
</th>
|
||||||
<th class="th-resizable" data-sort-key="atom" aria-sort="none">
|
<th
|
||||||
|
class="th-resizable"
|
||||||
|
data-sort-key="atom"
|
||||||
|
aria-sort="none"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="th-button"
|
class="th-button"
|
||||||
@@ -606,6 +645,14 @@ app.get("/", async (c) => {
|
|||||||
title="Resize"
|
title="Resize"
|
||||||
></div>
|
></div>
|
||||||
</th>
|
</th>
|
||||||
|
<th class="th-resizable">
|
||||||
|
<span>Expires</span>
|
||||||
|
<div
|
||||||
|
class="col-resizer"
|
||||||
|
data-col="expires"
|
||||||
|
title="Resize"
|
||||||
|
></div>
|
||||||
|
</th>
|
||||||
<th class="th-resizable">
|
<th class="th-resizable">
|
||||||
<span>Actions</span>
|
<span>Actions</span>
|
||||||
<div
|
<div
|
||||||
@@ -628,14 +675,20 @@ app.get("/", async (c) => {
|
|||||||
const sortEmail = emailAddress.toLowerCase();
|
const sortEmail = emailAddress.toLowerCase();
|
||||||
const sortRss = rssUrl.toLowerCase();
|
const sortRss = rssUrl.toLowerCase();
|
||||||
const sortAtom = atomUrl.toLowerCase();
|
const sortAtom = atomUrl.toLowerCase();
|
||||||
const descDisplay = clampText(feed.description || "", 220);
|
const descDisplay = clampText(
|
||||||
|
feed.description || "",
|
||||||
|
220,
|
||||||
|
);
|
||||||
const descHover = clampText(feed.description || "", 1000);
|
const descHover = clampText(feed.description || "", 1000);
|
||||||
const searchHaystack =
|
const searchHaystack =
|
||||||
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
|
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
|
||||||
|
const isExpired =
|
||||||
|
feed.expires_at !== undefined &&
|
||||||
|
feed.expires_at <= Date.now();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
class="feed-row"
|
class={`feed-row${isExpired ? " feed-expired" : ""}`}
|
||||||
data-feed-id={feed.id}
|
data-feed-id={feed.id}
|
||||||
data-search={searchHaystack}
|
data-search={searchHaystack}
|
||||||
data-sort-title={sortTitle}
|
data-sort-title={sortTitle}
|
||||||
@@ -654,6 +707,16 @@ app.get("/", async (c) => {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
<div class="feed-title-cell">
|
||||||
|
<img
|
||||||
|
class="feed-icon"
|
||||||
|
src={`/favicon/${feed.id}`}
|
||||||
|
alt=""
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
<strong class="truncate" title={titleHover}>
|
<strong class="truncate" title={titleHover}>
|
||||||
{titleDisplay}
|
{titleDisplay}
|
||||||
</strong>
|
</strong>
|
||||||
@@ -666,6 +729,8 @@ app.get("/", async (c) => {
|
|||||||
{descDisplay}
|
{descDisplay}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<code>{feed.id}</code>
|
<code>{feed.id}</code>
|
||||||
@@ -679,8 +744,34 @@ app.get("/", async (c) => {
|
|||||||
<td>
|
<td>
|
||||||
<CopyFieldInline value={atomUrl} />
|
<CopyFieldInline value={atomUrl} />
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{feed.expires_at ? (
|
||||||
|
<ExpiryBadge expiresAt={feed.expires_at} />
|
||||||
|
) : (
|
||||||
|
<span class="muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
|
{isExpired ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
class="button button-small button-disabled"
|
||||||
|
aria-disabled="true"
|
||||||
|
tabindex={-1}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="button button-small button-disabled"
|
||||||
|
aria-disabled="true"
|
||||||
|
tabindex={-1}
|
||||||
|
>
|
||||||
|
Emails
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<a
|
<a
|
||||||
href={`/admin/feeds/${feed.id}/edit`}
|
href={`/admin/feeds/${feed.id}/edit`}
|
||||||
class="button button-small"
|
class="button button-small"
|
||||||
@@ -693,6 +784,8 @@ app.get("/", async (c) => {
|
|||||||
>
|
>
|
||||||
Emails
|
Emails
|
||||||
</a>
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button button-small button-danger button-delete"
|
class="button button-small button-danger button-delete"
|
||||||
@@ -738,17 +831,31 @@ app.get("/", async (c) => {
|
|||||||
const descHover = clampText(feed.description || "", 1000);
|
const descHover = clampText(feed.description || "", 1000);
|
||||||
const searchHaystack =
|
const searchHaystack =
|
||||||
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
|
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
|
||||||
|
const isExpired =
|
||||||
|
feed.expires_at !== undefined &&
|
||||||
|
feed.expires_at <= Date.now();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
class="feed-item card feed-row"
|
class={`feed-item card feed-row${isExpired ? " feed-expired" : ""}`}
|
||||||
data-feed-id={feed.id}
|
data-feed-id={feed.id}
|
||||||
data-search={searchHaystack}
|
data-search={searchHaystack}
|
||||||
>
|
>
|
||||||
<div class="feed-header">
|
<div class="feed-header">
|
||||||
<h3 class="feed-title" title={titleHover}>
|
<h3 class="feed-title" title={titleHover}>
|
||||||
|
<img
|
||||||
|
class="feed-icon"
|
||||||
|
src={`/favicon/${feed.id}`}
|
||||||
|
alt=""
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
{titleDisplay}
|
{titleDisplay}
|
||||||
</h3>
|
</h3>
|
||||||
|
{feed.expires_at && (
|
||||||
|
<ExpiryBadge expiresAt={feed.expires_at} />
|
||||||
|
)}
|
||||||
{feed.description && (
|
{feed.description && (
|
||||||
<p class="feed-description">
|
<p class="feed-description">
|
||||||
<span title={descHover}>{descDisplay}</span>
|
<span title={descHover}>{descDisplay}</span>
|
||||||
@@ -809,6 +916,25 @@ app.get("/", async (c) => {
|
|||||||
|
|
||||||
<div class="feed-buttons">
|
<div class="feed-buttons">
|
||||||
<div class="feed-buttons-left">
|
<div class="feed-buttons-left">
|
||||||
|
{isExpired ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
class="button button-small button-disabled"
|
||||||
|
aria-disabled="true"
|
||||||
|
tabindex={-1}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="button button-small button-disabled"
|
||||||
|
aria-disabled="true"
|
||||||
|
tabindex={-1}
|
||||||
|
>
|
||||||
|
Emails
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<a
|
<a
|
||||||
href={`/admin/feeds/${feed.id}/edit`}
|
href={`/admin/feeds/${feed.id}/edit`}
|
||||||
class="button button-small"
|
class="button button-small"
|
||||||
@@ -821,6 +947,8 @@ app.get("/", async (c) => {
|
|||||||
>
|
>
|
||||||
Emails
|
Emails
|
||||||
</a>
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="feed-buttons-right">
|
<div class="feed-buttons-right">
|
||||||
<button
|
<button
|
||||||
@@ -863,45 +991,28 @@ app.post(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
// Type assertion for environment variables
|
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { title, description } = c.req.valid("json");
|
const { title, description } = c.req.valid("json");
|
||||||
const parsedData = { title, description, language: "en" as const };
|
|
||||||
|
|
||||||
// Get existing feed config
|
// Quick-edit: only title/description, expiry untouched.
|
||||||
const feedConfigKey = `feed:${feedId}:config`;
|
const result = await editFeedDetails(env, FeedId.unchecked(feedId), {
|
||||||
const existingConfig = (await emailStorage.get(feedConfigKey, {
|
title,
|
||||||
type: "json",
|
description,
|
||||||
})) as FeedConfig | null;
|
});
|
||||||
|
|
||||||
if (!existingConfig) {
|
if (result.status === "not_found") {
|
||||||
return c.json({ error: "Feed not found" }, 404);
|
return c.json({ error: "Feed not found" }, 404);
|
||||||
}
|
}
|
||||||
|
if (result.status === "expired") {
|
||||||
// Update feed configuration
|
return c.json(
|
||||||
await emailStorage.put(
|
{ error: "Feed has expired and cannot be modified." },
|
||||||
feedConfigKey,
|
403,
|
||||||
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 });
|
return c.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error updating feed via API", { error: String(error) });
|
logger.error("Error updating feed via API", { error: String(error) });
|
||||||
|
|||||||
+123
-84
@@ -1,16 +1,24 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import {
|
import { Env, EmailMetadata } from "../../types";
|
||||||
Env,
|
import { logger } from "../../infrastructure/logger";
|
||||||
FeedConfig,
|
|
||||||
FeedMetadata,
|
|
||||||
EmailData,
|
|
||||||
EmailMetadata,
|
|
||||||
} from "../../types";
|
|
||||||
import { logger } from "../../lib/logger";
|
|
||||||
import { Layout, clampText } from "./ui";
|
import { Layout, clampText } from "./ui";
|
||||||
import { deleteKeysWithConcurrency } from "./helpers";
|
import {
|
||||||
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../../utils/urls";
|
deleteAttachmentsForEmails,
|
||||||
|
deleteKeysWithConcurrency,
|
||||||
|
} from "../../application/feed-cleanup";
|
||||||
|
import { FeedRepository } from "../../infrastructure/feed-repository";
|
||||||
|
import { FeedId } from "../../domain/value-objects/feed-id";
|
||||||
|
import {
|
||||||
|
feedRssUrl,
|
||||||
|
feedAtomUrl,
|
||||||
|
feedEmailAddress,
|
||||||
|
baseUrl,
|
||||||
|
} from "../../infrastructure/urls";
|
||||||
|
import { processEmailContent } from "../../infrastructure/html-processor";
|
||||||
|
import { formatBytes } from "../../domain/format";
|
||||||
|
import { EmailAddress } from "../../domain/value-objects/email-address";
|
||||||
import { emailsPageScript } from "../../scripts/generated/emails-page";
|
import { emailsPageScript } from "../../scripts/generated/emails-page";
|
||||||
|
import emailPreviewCss from "../../styles/email-preview.css";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
|
|
||||||
@@ -72,19 +80,15 @@ const CopyField = ({ label, value, display }: CopyFieldProps) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
function extractSenderEmail(from: string): string {
|
|
||||||
const match = from.match(/<([^>]+@[^>]+)>/);
|
|
||||||
return match ? match[1].trim().toLowerCase() : from.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
type SenderFieldProps = {
|
type SenderFieldProps = {
|
||||||
from: string;
|
from: string;
|
||||||
feedId: string;
|
feedId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SenderField = ({ from, feedId }: SenderFieldProps) => {
|
const SenderField = ({ from, feedId }: SenderFieldProps) => {
|
||||||
const senderEmail = extractSenderEmail(from);
|
const parsed = EmailAddress.parse(from);
|
||||||
const senderDomain = senderEmail.split("@")[1] || "";
|
const senderEmail = parsed?.normalized ?? from.trim().toLowerCase();
|
||||||
|
const senderDomain = parsed?.domain.value ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="copyable">
|
<div class="copyable">
|
||||||
@@ -152,17 +156,14 @@ const SenderField = ({ from, feedId }: SenderFieldProps) => {
|
|||||||
|
|
||||||
emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
const repo = FeedRepository.from(env);
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
const message = c.req.query("message");
|
const message = c.req.query("message");
|
||||||
const count = Number(c.req.query("count") || "0");
|
const count = Number(c.req.query("count") || "0");
|
||||||
|
|
||||||
const feedConfig = (await emailStorage.get(`feed:${feedId}:config`, {
|
const id = FeedId.unchecked(feedId);
|
||||||
type: "json",
|
const feedConfig = await repo.getConfig(id);
|
||||||
})) as FeedConfig | null;
|
const feedMetadata = await repo.getMetadata(id);
|
||||||
const feedMetadata = (await emailStorage.get(`feed:${feedId}:metadata`, {
|
|
||||||
type: "json",
|
|
||||||
})) as FeedMetadata | null;
|
|
||||||
|
|
||||||
if (!feedConfig || !feedMetadata) {
|
if (!feedConfig || !feedMetadata) {
|
||||||
return c.text("Feed not found", 404);
|
return c.text("Feed not found", 404);
|
||||||
@@ -349,6 +350,10 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
{feedMetadata.emails.map((email: EmailMetadata) => {
|
{feedMetadata.emails.map((email: EmailMetadata) => {
|
||||||
const subjectDisplay = clampText(email.subject, 180);
|
const subjectDisplay = clampText(email.subject, 180);
|
||||||
const subjectHover = clampText(email.subject, 1000);
|
const subjectHover = clampText(email.subject, 1000);
|
||||||
|
const attachmentCount = email.attachmentIds?.length ?? 0;
|
||||||
|
const attachmentLabel = `${attachmentCount} attachment${
|
||||||
|
attachmentCount > 1 ? "s" : ""
|
||||||
|
}`;
|
||||||
const sortSubject = subjectHover.toLowerCase();
|
const sortSubject = subjectHover.toLowerCase();
|
||||||
const sortReceivedAt = String(email.receivedAt);
|
const sortReceivedAt = String(email.receivedAt);
|
||||||
const searchHaystack = clampText(
|
const searchHaystack = clampText(
|
||||||
@@ -373,9 +378,32 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
<div class="subject-cell">
|
||||||
|
{attachmentCount > 0 ? (
|
||||||
|
<span
|
||||||
|
class="attachment-indicator"
|
||||||
|
title={attachmentLabel}
|
||||||
|
aria-label={attachmentLabel}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span class="truncate" title={subjectHover}>
|
<span class="truncate" title={subjectHover}>
|
||||||
{subjectDisplay}
|
{subjectDisplay}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{new Date(email.receivedAt).toLocaleString()}</td>
|
<td>{new Date(email.receivedAt).toLocaleString()}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -415,7 +443,11 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Config bootstrap — injects dynamic server-side data before the static compiled script */}
|
{/* Config bootstrap — injects dynamic server-side data before the static compiled script */}
|
||||||
<script dangerouslySetInnerHTML={{ __html: `window.__APP_CONFIG__=${JSON.stringify({ feedId })}` }} />
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `window.__APP_CONFIG__=${JSON.stringify({ feedId })}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Emails page logic compiled from src/scripts/client/emails-page.ts */}
|
{/* Emails page logic compiled from src/scripts/client/emails-page.ts */}
|
||||||
<script dangerouslySetInnerHTML={{ __html: emailsPageScript }} />
|
<script dangerouslySetInnerHTML={{ __html: emailsPageScript }} />
|
||||||
</Layout>,
|
</Layout>,
|
||||||
@@ -426,18 +458,26 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
|
|
||||||
emailsRouter.get("/emails/:emailKey", async (c) => {
|
emailsRouter.get("/emails/:emailKey", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
const repo = FeedRepository.from(env);
|
||||||
const emailKey = c.req.param("emailKey");
|
const emailKey = c.req.param("emailKey");
|
||||||
|
|
||||||
const emailData = (await emailStorage.get(emailKey, {
|
const emailData = await repo.getEmail(emailKey);
|
||||||
type: "json",
|
|
||||||
})) as EmailData | null;
|
|
||||||
|
|
||||||
if (!emailData) return c.text("Email not found", 404);
|
if (!emailData) return c.text("Email not found", 404);
|
||||||
|
|
||||||
const feedId = emailKey.split(":")[1];
|
const feedId = repo.feedIdFromEmailKey(emailKey);
|
||||||
|
// Inline images render in place; only downloadable attachments go in the list.
|
||||||
|
const attachments = (emailData.attachments ?? []).filter((a) => !a.inline);
|
||||||
|
|
||||||
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>`;
|
// The rendered preview lives in a `data:` iframe, which has no origin to
|
||||||
|
// resolve relative URLs against — so cid: refs must be rewritten to absolute
|
||||||
|
// /files URLs (and the content sanitized) before embedding.
|
||||||
|
const renderedBody = processEmailContent(
|
||||||
|
emailData.content,
|
||||||
|
emailData.attachments,
|
||||||
|
baseUrl(env),
|
||||||
|
);
|
||||||
|
const htmlContent = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>${emailPreviewCss}</style></head><body>${renderedBody}</body></html>`;
|
||||||
|
|
||||||
const encodedHtmlContent = (() => {
|
const encodedHtmlContent = (() => {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
@@ -445,9 +485,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
|
|||||||
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const rawHtml = emailData.content
|
const rawHtml = emailData.content.replace(/</g, "<").replace(/>/g, ">");
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
|
|
||||||
const viewScript = `
|
const viewScript = `
|
||||||
function showRendered() {
|
function showRendered() {
|
||||||
@@ -546,10 +584,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
|
|||||||
value={new Date(emailData.receivedAt).toLocaleString()}
|
value={new Date(emailData.receivedAt).toLocaleString()}
|
||||||
/>
|
/>
|
||||||
<SenderField from={emailData.from} feedId={feedId} />
|
<SenderField from={emailData.from} feedId={feedId} />
|
||||||
<CopyField
|
<CopyField label="To:" value={feedEmailAddress(feedId, env)} />
|
||||||
label="To:"
|
|
||||||
value={feedEmailAddress(feedId, env)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -577,6 +612,38 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
|
|||||||
<pre dangerouslySetInnerHTML={{ __html: rawHtml }}></pre>
|
<pre dangerouslySetInnerHTML={{ __html: rawHtml }}></pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div class="attachments">
|
||||||
|
<h2>Attachments</h2>
|
||||||
|
<ul class="attachment-list">
|
||||||
|
{attachments.map((a) => (
|
||||||
|
<li>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||||
|
</svg>
|
||||||
|
<a
|
||||||
|
href={`/files/${a.id}/${encodeURIComponent(a.filename)}`}
|
||||||
|
download
|
||||||
|
>
|
||||||
|
{a.filename}
|
||||||
|
</a>
|
||||||
|
<span class="attachment-size">{formatBytes(a.size)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -589,7 +656,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
|
|||||||
|
|
||||||
emailsRouter.post("/emails/:emailKey/delete", async (c) => {
|
emailsRouter.post("/emails/:emailKey/delete", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
const repo = FeedRepository.from(env);
|
||||||
const emailKey = c.req.param("emailKey");
|
const emailKey = c.req.param("emailKey");
|
||||||
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
|
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
|
||||||
|
|
||||||
@@ -601,26 +668,13 @@ emailsRouter.post("/emails/:emailKey/delete", async (c) => {
|
|||||||
return c.text("Feed ID is required", 400);
|
return c.text("Feed ID is required", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedMetadataKey = `feed:${feedId}:metadata`;
|
const feed = await repo.load(FeedId.unchecked(feedId));
|
||||||
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);
|
await repo.deleteEmail(emailKey);
|
||||||
|
if (feed) {
|
||||||
if (feedMetadata) {
|
const { removed } = feed.removeEmails([emailKey]);
|
||||||
feedMetadata.emails = feedMetadata.emails.filter(
|
await deleteAttachmentsForEmails(env, removed, [emailKey]);
|
||||||
(email) => email.key !== emailKey,
|
await repo.saveMetadata(feed);
|
||||||
);
|
|
||||||
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 });
|
if (wantsJson) return c.json({ ok: true, emailKey, feedId });
|
||||||
@@ -641,6 +695,7 @@ emailsRouter.post("/emails/:emailKey/delete", async (c) => {
|
|||||||
emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
|
emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
const emailStorage = env.EMAIL_STORAGE;
|
||||||
|
const repo = new FeedRepository(emailStorage);
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
const contentType = c.req.header("Content-Type") || "";
|
const contentType = c.req.header("Content-Type") || "";
|
||||||
const wantsJson =
|
const wantsJson =
|
||||||
@@ -648,18 +703,15 @@ emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
|
|||||||
(c.req.header("Accept") || "").includes("application/json");
|
(c.req.header("Accept") || "").includes("application/json");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const feedMetadataKey = `feed:${feedId}:metadata`;
|
const feed = await repo.load(FeedId.unchecked(feedId));
|
||||||
const feedMetadata = (await emailStorage.get(feedMetadataKey, {
|
|
||||||
type: "json",
|
|
||||||
})) as FeedMetadata | null;
|
|
||||||
|
|
||||||
if (!feedMetadata) {
|
if (!feed) {
|
||||||
return wantsJson
|
return wantsJson
|
||||||
? c.json({ ok: false, error: "Feed not found" }, 404)
|
? c.json({ ok: false, error: "Feed not found" }, 404)
|
||||||
: c.text("Feed not found", 404);
|
: c.text("Feed not found", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowedKeys = new Set(feedMetadata.emails.map((email) => email.key));
|
const allowedKeys = new Set(feed.emails.map((email) => email.key));
|
||||||
|
|
||||||
if (wantsJson) {
|
if (wantsJson) {
|
||||||
const body = (await c.req.json().catch(() => null)) as {
|
const body = (await c.req.json().catch(() => null)) as {
|
||||||
@@ -686,25 +738,13 @@ emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const candidates = emailKeys.filter((key) => allowedKeys.has(key));
|
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 } =
|
const { ok: deletedOk, failed: failedEmailKeys } =
|
||||||
await deleteKeysWithConcurrency(emailStorage, candidates, 35);
|
await deleteKeysWithConcurrency(emailStorage, candidates, 35);
|
||||||
|
await deleteAttachmentsForEmails(env, feed.emails, candidates);
|
||||||
|
|
||||||
const deletedSet = new Set(deletedOk);
|
feed.removeEmails(deletedOk);
|
||||||
feedMetadata.emails = feedMetadata.emails.filter(
|
await repo.saveMetadata(feed);
|
||||||
(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({
|
return c.json({
|
||||||
ok: failedEmailKeys.length === 0,
|
ok: failedEmailKeys.length === 0,
|
||||||
@@ -723,17 +763,16 @@ emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
|
|||||||
return c.redirect(`/admin/feeds/${feedId}/emails?message=bulkDeleteNoop`);
|
return c.redirect(`/admin/feeds/${feedId}/emails?message=bulkDeleteNoop`);
|
||||||
|
|
||||||
const candidates = emailKeys.filter((key) => allowedKeys.has(key));
|
const candidates = emailKeys.filter((key) => allowedKeys.has(key));
|
||||||
|
|
||||||
const { ok: deletedOk } = await deleteKeysWithConcurrency(
|
const { ok: deletedOk } = await deleteKeysWithConcurrency(
|
||||||
emailStorage,
|
emailStorage,
|
||||||
candidates,
|
candidates,
|
||||||
35,
|
35,
|
||||||
);
|
);
|
||||||
|
await deleteAttachmentsForEmails(env, feed.emails, candidates);
|
||||||
|
|
||||||
const deletedSet = new Set(deletedOk);
|
feed.removeEmails(deletedOk);
|
||||||
feedMetadata.emails = feedMetadata.emails.filter(
|
await repo.saveMetadata(feed);
|
||||||
(email) => !deletedSet.has(email.key),
|
|
||||||
);
|
|
||||||
await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata));
|
|
||||||
|
|
||||||
return c.redirect(
|
return c.redirect(
|
||||||
`/admin/feeds/${feedId}/emails?message=bulkDeleted&count=${deletedOk.length}`,
|
`/admin/feeds/${feedId}/emails?message=bulkDeleted&count=${deletedOk.length}`,
|
||||||
|
|||||||
+177
-194
@@ -1,18 +1,25 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Env, FeedConfig, FeedMetadata, EmailData } from "../../types";
|
import { Env } from "../../types";
|
||||||
import { generateFeedId } from "../../utils/id-generator";
|
import { bumpCounters } from "../../application/stats";
|
||||||
import { waitUntilSafe } from "../../utils/worker";
|
import { waitUntilSafe } from "../../infrastructure/worker";
|
||||||
import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
|
import { feedRssUrl, feedEmailAddress } from "../../infrastructure/urls";
|
||||||
import { logger } from "../../lib/logger";
|
import { logger } from "../../infrastructure/logger";
|
||||||
|
import { sendUnsubscribes } from "../../infrastructure/unsubscribe";
|
||||||
|
import { getAttachmentBucket } from "../../infrastructure/attachments";
|
||||||
import { Layout } from "./ui";
|
import { Layout } from "./ui";
|
||||||
import {
|
import {
|
||||||
addFeedToList,
|
purgeFeedKeysStep,
|
||||||
updateFeedInList,
|
collectUnsubscribeUrls,
|
||||||
removeFeedFromList,
|
} from "../../application/feed-cleanup";
|
||||||
removeFeedsFromListBulk,
|
import { FeedRepository } from "../../infrastructure/feed-repository";
|
||||||
deleteKeysWithConcurrency,
|
import { FeedId } from "../../domain/value-objects/feed-id";
|
||||||
} from "./helpers";
|
import {
|
||||||
|
createFeedRecord,
|
||||||
|
editFeed,
|
||||||
|
deleteFeedRecord,
|
||||||
|
deleteFeedFastDetailed,
|
||||||
|
} from "../../application/feed-service";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
|
|
||||||
@@ -43,117 +50,19 @@ const updateFeedSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const senderFilterSchema = z.object({
|
const senderFilterSchema = z.object({
|
||||||
action: z.enum(["allow_sender", "allow_domain", "block_sender", "block_domain"]),
|
action: z.enum([
|
||||||
|
"allow_sender",
|
||||||
|
"allow_domain",
|
||||||
|
"block_sender",
|
||||||
|
"block_domain",
|
||||||
|
]),
|
||||||
value: z.string().min(1),
|
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 ────────────────────────────────────────────────────────────────────
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
feedsRouter.post("/create", async (c) => {
|
feedsRouter.post("/create", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
|
||||||
const isJson =
|
const isJson =
|
||||||
c.req.header("Content-Type")?.includes("application/json") ?? false;
|
c.req.header("Content-Type")?.includes("application/json") ?? false;
|
||||||
|
|
||||||
@@ -164,6 +73,7 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
let view: string;
|
let view: string;
|
||||||
let allowedSenders: string[];
|
let allowedSenders: string[];
|
||||||
let blockedSenders: string[];
|
let blockedSenders: string[];
|
||||||
|
let lifetimeHoursRaw: string | undefined;
|
||||||
|
|
||||||
if (isJson) {
|
if (isJson) {
|
||||||
const body = await c.req.json<Record<string, unknown>>();
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
@@ -182,6 +92,8 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
(body.blockedSenders as unknown[]).map(String),
|
(body.blockedSenders as unknown[]).map(String),
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
lifetimeHoursRaw =
|
||||||
|
body.lifetimeHours != null ? String(body.lifetimeHours) : undefined;
|
||||||
} else {
|
} else {
|
||||||
const formData = await c.req.formData();
|
const formData = await c.req.formData();
|
||||||
title = formData.get("title")?.toString() || "";
|
title = formData.get("title")?.toString() || "";
|
||||||
@@ -194,6 +106,7 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
blockedSenders = parseAllowedSenders(
|
blockedSenders = parseAllowedSenders(
|
||||||
formData.get("blocked_senders")?.toString() || "",
|
formData.get("blocked_senders")?.toString() || "",
|
||||||
);
|
);
|
||||||
|
lifetimeHoursRaw = formData.get("lifetime_hours")?.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedData = createFeedSchema.parse({
|
const parsedData = createFeedSchema.parse({
|
||||||
@@ -204,31 +117,18 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
blockedSenders,
|
blockedSenders,
|
||||||
});
|
});
|
||||||
|
|
||||||
const feedId = generateFeedId();
|
const lifetimeHours = lifetimeHoursRaw
|
||||||
|
? parseInt(lifetimeHoursRaw, 10)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const feedConfig: FeedConfig = {
|
const { feedId } = await createFeedRecord(env, {
|
||||||
title: parsedData.title,
|
title: parsedData.title,
|
||||||
description: parsedData.description,
|
description: parsedData.description,
|
||||||
language: parsedData.language,
|
language: parsedData.language,
|
||||||
allowed_senders: parsedData.allowedSenders,
|
allowedSenders: parsedData.allowedSenders,
|
||||||
blocked_senders: parsedData.blockedSenders,
|
blockedSenders: parsedData.blockedSenders,
|
||||||
created_at: Date.now(),
|
lifetimeHours,
|
||||||
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) {
|
if (isJson) {
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -238,7 +138,7 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.redirect(`/admin?view=${view}`);
|
return c.redirect(`/admin?view=${view}#your-feeds`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating feed", { error: String(error) });
|
logger.error("Error creating feed", { error: String(error) });
|
||||||
if (c.req.header("Content-Type")?.includes("application/json")) {
|
if (c.req.header("Content-Type")?.includes("application/json")) {
|
||||||
@@ -250,17 +150,32 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
|
|
||||||
feedsRouter.get("/:feedId/edit", async (c) => {
|
feedsRouter.get("/:feedId/edit", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
|
|
||||||
const feedConfig = (await emailStorage.get(`feed:${feedId}:config`, {
|
const feedConfig = await FeedRepository.from(env).getConfig(
|
||||||
type: "json",
|
FeedId.unchecked(feedId),
|
||||||
})) as FeedConfig | null;
|
);
|
||||||
|
|
||||||
if (!feedConfig) {
|
if (!feedConfig) {
|
||||||
return c.text("Feed not found", 404);
|
return c.text("Feed not found", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const isExpired =
|
||||||
|
feedConfig.expires_at !== undefined && feedConfig.expires_at <= now;
|
||||||
|
const ttlLocked = !!env.FEED_TTL_HOURS;
|
||||||
|
|
||||||
|
// Remaining hours: ceil so we don't show 0 when there's still time left
|
||||||
|
const remainingHours =
|
||||||
|
feedConfig.expires_at !== undefined && feedConfig.expires_at > now
|
||||||
|
? Math.ceil((feedConfig.expires_at - now) / 3_600_000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const lifetimeFieldValue =
|
||||||
|
ttlLocked && !isExpired
|
||||||
|
? (env.FEED_TTL_HOURS ?? "")
|
||||||
|
: (remainingHours?.toString() ?? "");
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title="Edit Feed">
|
<Layout title="Edit Feed">
|
||||||
<div class="container fade-in">
|
<div class="container fade-in">
|
||||||
@@ -275,7 +190,25 @@ feedsRouter.get("/:feedId/edit", async (c) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
{isExpired && (
|
||||||
|
<div class="card card-warning">
|
||||||
|
<p>
|
||||||
|
<strong>This feed has expired.</strong> It no longer accepts
|
||||||
|
emails and its content is no longer publicly accessible.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
action={`/admin/feeds/${feedId}/delete`}
|
||||||
|
method="post"
|
||||||
|
style="margin-top: 0.75rem;"
|
||||||
|
>
|
||||||
|
<button type="submit" class="button button-danger">
|
||||||
|
Delete this feed
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class={`card${isExpired ? " card-disabled" : ""}`}>
|
||||||
<form action={`/admin/feeds/${feedId}/edit`} method="post">
|
<form action={`/admin/feeds/${feedId}/edit`} method="post">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="title">Feed Title</label>
|
<label for="title">Feed Title</label>
|
||||||
@@ -285,12 +218,18 @@ feedsRouter.get("/:feedId/edit", async (c) => {
|
|||||||
name="title"
|
name="title"
|
||||||
value={feedConfig.title}
|
value={feedConfig.title}
|
||||||
required
|
required
|
||||||
|
disabled={isExpired}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="description">Description</label>
|
<label for="description">Description</label>
|
||||||
<textarea id="description" name="description" rows={3}>
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows={3}
|
||||||
|
disabled={isExpired}
|
||||||
|
>
|
||||||
{feedConfig.description || ""}
|
{feedConfig.description || ""}
|
||||||
</textarea>
|
</textarea>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,6 +243,7 @@ feedsRouter.get("/:feedId/edit", async (c) => {
|
|||||||
name="allowed_senders"
|
name="allowed_senders"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={"newsletter@example.com\ntechmeme.com"}
|
placeholder={"newsletter@example.com\ntechmeme.com"}
|
||||||
|
disabled={isExpired}
|
||||||
>
|
>
|
||||||
{(feedConfig.allowed_senders || []).join("\n")}
|
{(feedConfig.allowed_senders || []).join("\n")}
|
||||||
</textarea>
|
</textarea>
|
||||||
@@ -322,6 +262,7 @@ feedsRouter.get("/:feedId/edit", async (c) => {
|
|||||||
name="blocked_senders"
|
name="blocked_senders"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={"spam@example.com\nunwanted.com"}
|
placeholder={"spam@example.com\nunwanted.com"}
|
||||||
|
disabled={isExpired}
|
||||||
>
|
>
|
||||||
{(feedConfig.blocked_senders || []).join("\n")}
|
{(feedConfig.blocked_senders || []).join("\n")}
|
||||||
</textarea>
|
</textarea>
|
||||||
@@ -331,11 +272,37 @@ feedsRouter.get("/:feedId/edit", async (c) => {
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lifetime_hours">Lifetime (hours)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="lifetime_hours"
|
||||||
|
name="lifetime_hours"
|
||||||
|
min="1"
|
||||||
|
value={lifetimeFieldValue}
|
||||||
|
disabled={isExpired || ttlLocked}
|
||||||
|
placeholder={feedConfig.expires_at ? undefined : "No expiry"}
|
||||||
|
/>
|
||||||
|
{ttlLocked ? (
|
||||||
|
<small>
|
||||||
|
Feed lifetime is fixed to {env.FEED_TTL_HOURS}h by server
|
||||||
|
configuration.
|
||||||
|
</small>
|
||||||
|
) : (
|
||||||
|
<small>
|
||||||
|
Hours from now until this feed expires. Leave empty to keep
|
||||||
|
the current expiry (or no expiry).
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<input type="hidden" id="language" name="language" value="en" />
|
<input type="hidden" id="language" name="language" value="en" />
|
||||||
|
|
||||||
|
{!isExpired && (
|
||||||
<button type="submit" class="button">
|
<button type="submit" class="button">
|
||||||
Update Feed
|
Update Feed
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,7 +312,6 @@ feedsRouter.get("/:feedId/edit", async (c) => {
|
|||||||
|
|
||||||
feedsRouter.post("/:feedId/edit", async (c) => {
|
feedsRouter.post("/:feedId/edit", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -359,6 +325,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
|||||||
const blockedSenders = parseAllowedSenders(
|
const blockedSenders = parseAllowedSenders(
|
||||||
formData.get("blocked_senders")?.toString() || "",
|
formData.get("blocked_senders")?.toString() || "",
|
||||||
);
|
);
|
||||||
|
const lifetimeHoursRaw = formData.get("lifetime_hours")?.toString();
|
||||||
|
|
||||||
const parsedData = updateFeedSchema.parse({
|
const parsedData = updateFeedSchema.parse({
|
||||||
title,
|
title,
|
||||||
@@ -368,34 +335,23 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
|||||||
blockedSenders,
|
blockedSenders,
|
||||||
});
|
});
|
||||||
|
|
||||||
const feedConfigKey = `feed:${feedId}:config`;
|
const result = await editFeed(env, FeedId.unchecked(feedId), {
|
||||||
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,
|
title: parsedData.title,
|
||||||
description: parsedData.description,
|
description: parsedData.description,
|
||||||
language: parsedData.language,
|
language: parsedData.language,
|
||||||
allowed_senders: parsedData.allowedSenders,
|
allowedSenders: parsedData.allowedSenders,
|
||||||
blocked_senders: parsedData.blockedSenders,
|
blockedSenders: parsedData.blockedSenders,
|
||||||
updated_at: Date.now(),
|
lifetimeHours: lifetimeHoursRaw
|
||||||
}),
|
? parseInt(lifetimeHoursRaw, 10)
|
||||||
);
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
await updateFeedInList(
|
if (result.status === "not_found") {
|
||||||
emailStorage,
|
return c.text("Feed not found", 404);
|
||||||
feedId,
|
}
|
||||||
parsedData.title,
|
if (result.status === "expired") {
|
||||||
parsedData.description,
|
return c.text("Feed has expired and cannot be modified.", 403);
|
||||||
);
|
}
|
||||||
|
|
||||||
return c.redirect("/admin");
|
return c.redirect("/admin");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -409,7 +365,8 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
|||||||
feedsRouter.post("/:feedId/sender-filter", async (c) => {
|
feedsRouter.post("/:feedId/sender-filter", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
const feedConfigKey = `feed:${feedId}:config`;
|
const id = FeedId.unchecked(feedId);
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const parsed = senderFilterSchema.safeParse(body);
|
const parsed = senderFilterSchema.safeParse(body);
|
||||||
@@ -420,9 +377,7 @@ feedsRouter.post("/:feedId/sender-filter", async (c) => {
|
|||||||
const { action, value } = parsed.data;
|
const { action, value } = parsed.data;
|
||||||
const normalized = value.trim().toLowerCase();
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
const feedConfig = (await env.EMAIL_STORAGE.get(feedConfigKey, {
|
const feedConfig = await repo.getConfig(id);
|
||||||
type: "json",
|
|
||||||
})) as FeedConfig | null;
|
|
||||||
if (!feedConfig) return c.json({ ok: false, error: "Feed not found" }, 404);
|
if (!feedConfig) return c.json({ ok: false, error: "Feed not found" }, 404);
|
||||||
|
|
||||||
const allowedSenders = (feedConfig.allowed_senders || []).map((s) =>
|
const allowedSenders = (feedConfig.allowed_senders || []).map((s) =>
|
||||||
@@ -449,15 +404,12 @@ feedsRouter.post("/:feedId/sender-filter", async (c) => {
|
|||||||
|
|
||||||
if (!targetList.includes(normalized)) {
|
if (!targetList.includes(normalized)) {
|
||||||
targetList.push(normalized);
|
targetList.push(normalized);
|
||||||
await env.EMAIL_STORAGE.put(
|
await repo.putConfig(id, {
|
||||||
feedConfigKey,
|
|
||||||
JSON.stringify({
|
|
||||||
...feedConfig,
|
...feedConfig,
|
||||||
allowed_senders: allowedSenders,
|
allowed_senders: allowedSenders,
|
||||||
blocked_senders: blockedSenders,
|
blocked_senders: blockedSenders,
|
||||||
updated_at: Date.now(),
|
updated_at: Date.now(),
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
@@ -465,20 +417,13 @@ feedsRouter.post("/:feedId/sender-filter", async (c) => {
|
|||||||
|
|
||||||
feedsRouter.post("/:feedId/delete", async (c) => {
|
feedsRouter.post("/:feedId/delete", async (c) => {
|
||||||
const env = c.env;
|
const env = c.env;
|
||||||
const emailStorage = env.EMAIL_STORAGE;
|
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
const view = c.req.query("view") === "table" ? "table" : "list";
|
const view = c.req.query("view") === "table" ? "table" : "list";
|
||||||
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
|
const wantsJson = (c.req.header("Accept") || "").includes("application/json");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteFeedFast(emailStorage, feedId);
|
await deleteFeedRecord(env, FeedId.unchecked(feedId), (p) =>
|
||||||
await removeFeedFromList(emailStorage, feedId);
|
waitUntilSafe(c, p),
|
||||||
|
|
||||||
waitUntilSafe(
|
|
||||||
c,
|
|
||||||
purgeFeedKeysStep(emailStorage, feedId, {
|
|
||||||
bucket: env.ATTACHMENT_BUCKET,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (wantsJson) {
|
if (wantsJson) {
|
||||||
@@ -513,11 +458,15 @@ feedsRouter.post("/:feedId/purge", async (c) => {
|
|||||||
? Number(body?.limit)
|
? Number(body?.limit)
|
||||||
: 100;
|
: 100;
|
||||||
|
|
||||||
const step = await purgeFeedKeysStep(emailStorage, feedId, {
|
const step = await purgeFeedKeysStep(
|
||||||
|
emailStorage,
|
||||||
|
FeedId.unchecked(feedId),
|
||||||
|
{
|
||||||
cursor,
|
cursor,
|
||||||
limit,
|
limit,
|
||||||
bucket: env.ATTACHMENT_BUCKET,
|
bucket: getAttachmentBucket(env),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
ok: step.failedKeys.length === 0,
|
ok: step.failedKeys.length === 0,
|
||||||
@@ -569,10 +518,14 @@ feedsRouter.post("/bulk-delete", async (c) => {
|
|||||||
const okIds: string[] = [];
|
const okIds: string[] = [];
|
||||||
const failures: Array<{ feedId: string; error: string }> = [];
|
const failures: Array<{ feedId: string; error: string }> = [];
|
||||||
const warnings: Array<{ feedId: string; warning: string }> = [];
|
const warnings: Array<{ feedId: string; warning: string }> = [];
|
||||||
|
const unsubscribeUrls: string[] = [];
|
||||||
|
|
||||||
for (const feedId of parsedFeedIds) {
|
for (const feedId of parsedFeedIds) {
|
||||||
try {
|
try {
|
||||||
const result = await deleteFeedFastDetailed(emailStorage, feedId);
|
const id = FeedId.unchecked(feedId);
|
||||||
|
// Read unsubscribe URLs before the feed metadata is deleted.
|
||||||
|
const urls = await collectUnsubscribeUrls(emailStorage, id);
|
||||||
|
const result = await deleteFeedFastDetailed(emailStorage, id);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
failures.push({
|
failures.push({
|
||||||
feedId,
|
feedId,
|
||||||
@@ -591,6 +544,7 @@ feedsRouter.post("/bulk-delete", async (c) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unsubscribeUrls.push(...urls);
|
||||||
okIds.push(feedId);
|
okIds.push(feedId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error bulk deleting feed", {
|
logger.error("Error bulk deleting feed", {
|
||||||
@@ -601,7 +555,18 @@ feedsRouter.post("/bulk-delete", async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
|
const deletedFeedIds = await new FeedRepository(
|
||||||
|
emailStorage,
|
||||||
|
).removeFromListBulk(okIds);
|
||||||
|
if (deletedFeedIds.length > 0) {
|
||||||
|
await bumpCounters(emailStorage, {
|
||||||
|
feeds_deleted: deletedFeedIds.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unsubscribeUrls.length > 0) {
|
||||||
|
waitUntilSafe(c, sendUnsubscribes(unsubscribeUrls, env));
|
||||||
|
}
|
||||||
|
|
||||||
const removed = new Set(deletedFeedIds);
|
const removed = new Set(deletedFeedIds);
|
||||||
okIds.forEach((feedId) => {
|
okIds.forEach((feedId) => {
|
||||||
@@ -637,11 +602,18 @@ feedsRouter.post("/bulk-delete", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const okIds: string[] = [];
|
const okIds: string[] = [];
|
||||||
|
const unsubscribeUrls: string[] = [];
|
||||||
|
|
||||||
for (const feedId of parsedFeedIds) {
|
for (const feedId of parsedFeedIds) {
|
||||||
try {
|
try {
|
||||||
const result = await deleteFeedFastDetailed(emailStorage, feedId);
|
const id = FeedId.unchecked(feedId);
|
||||||
if (result.ok) okIds.push(feedId);
|
// Read unsubscribe URLs before the feed metadata is deleted.
|
||||||
|
const urls = await collectUnsubscribeUrls(emailStorage, id);
|
||||||
|
const result = await deleteFeedFastDetailed(emailStorage, id);
|
||||||
|
if (result.ok) {
|
||||||
|
unsubscribeUrls.push(...urls);
|
||||||
|
okIds.push(feedId);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error bulk deleting feed", {
|
logger.error("Error bulk deleting feed", {
|
||||||
feedId,
|
feedId,
|
||||||
@@ -650,7 +622,18 @@ feedsRouter.post("/bulk-delete", async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
|
const deletedFeedIds = await new FeedRepository(
|
||||||
|
emailStorage,
|
||||||
|
).removeFromListBulk(okIds);
|
||||||
|
if (deletedFeedIds.length > 0) {
|
||||||
|
await bumpCounters(emailStorage, {
|
||||||
|
feeds_deleted: deletedFeedIds.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unsubscribeUrls.length > 0) {
|
||||||
|
waitUntilSafe(c, sendUnsubscribes(unsubscribeUrls, env));
|
||||||
|
}
|
||||||
|
|
||||||
return c.redirect(
|
return c.redirect(
|
||||||
`${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`,
|
`${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`,
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
+32
-8
@@ -3,19 +3,27 @@ import layoutCss from "../../styles/layout.css";
|
|||||||
import componentsCss from "../../styles/components.css";
|
import componentsCss from "../../styles/components.css";
|
||||||
import utilitiesCss from "../../styles/utilities.css";
|
import utilitiesCss from "../../styles/utilities.css";
|
||||||
import { interactiveScripts } from "../../scripts/index";
|
import { interactiveScripts } from "../../scripts/index";
|
||||||
|
import { FAVICON_PATH } from "../favicon";
|
||||||
|
|
||||||
const designSystem = [variablesCss, layoutCss, componentsCss, utilitiesCss].join("\n");
|
const designSystem = [
|
||||||
|
variablesCss,
|
||||||
|
layoutCss,
|
||||||
|
componentsCss,
|
||||||
|
utilitiesCss,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
type LayoutProps = {
|
type LayoutProps = {
|
||||||
title: string;
|
title: string;
|
||||||
|
label?: string;
|
||||||
children: import("hono/jsx").Child;
|
children: import("hono/jsx").Child;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Layout = ({ title, children }: LayoutProps) => {
|
export const Layout = ({ title, label = "admin", children }: LayoutProps) => {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>{title} — kill-the-news</title>
|
<title>{title} — kill-the-news</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href={FAVICON_PATH} />
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
@@ -31,20 +39,36 @@ export const Layout = ({ title, children }: LayoutProps) => {
|
|||||||
/>
|
/>
|
||||||
{/* designSystem and interactiveScripts are static trusted strings, not user input */}
|
{/* designSystem and interactiveScripts are static trusted strings, not user input */}
|
||||||
<style dangerouslySetInnerHTML={{ __html: designSystem }} />
|
<style dangerouslySetInnerHTML={{ __html: designSystem }} />
|
||||||
<script dangerouslySetInnerHTML={{ __html: interactiveScripts + ";" }} />
|
<script
|
||||||
|
dangerouslySetInnerHTML={{ __html: interactiveScripts + ";" }}
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body class="page">
|
<body class="page">
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<a href="https://kill-the.news/" class="site-header-logo" target="_blank" rel="noopener">
|
<a
|
||||||
|
href="https://kill-the.news/"
|
||||||
|
class="site-header-logo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
kill-the-news
|
kill-the-news
|
||||||
</a>
|
</a>
|
||||||
<span class="site-header-label">admin</span>
|
<span class="site-header-label">{label}</span>
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<a href="https://kill-the.news/" target="_blank" rel="noopener">kill-the.news</a>
|
<a href="https://kill-the.news/" target="_blank" rel="noopener">
|
||||||
<span class="site-footer-sep" aria-hidden="true">·</span>
|
kill-the.news
|
||||||
<a href="https://github.com/sponsors/juherr" target="_blank" rel="noopener" class="site-footer-sponsor">
|
</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
|
♥ Sponsor
|
||||||
</a>
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { apiApp } from "./index";
|
||||||
|
import { createMockEnv } from "../../test/setup";
|
||||||
|
import { Env } from "../../types";
|
||||||
|
|
||||||
|
const PASSWORD = "test-password";
|
||||||
|
const authHeaders = { Authorization: `Bearer ${PASSWORD}` };
|
||||||
|
|
||||||
|
describe("REST API (/api/v1)", () => {
|
||||||
|
let testApp: Hono;
|
||||||
|
let mockEnv: Env;
|
||||||
|
let request: (path: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockEnv = createMockEnv() as unknown as Env;
|
||||||
|
testApp = new Hono();
|
||||||
|
testApp.route("/api", apiApp);
|
||||||
|
request = (path, init = {}) =>
|
||||||
|
Promise.resolve(testApp.request(path, init, mockEnv));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createFeed(title = "Test Feed"): Promise<string> {
|
||||||
|
const res = await request("/api/v1/feeds", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = (await res.json()) as { id: string };
|
||||||
|
return body.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Authentication", () => {
|
||||||
|
it("rejects requests without a token", async () => {
|
||||||
|
const res = await request("/api/v1/feeds");
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect((await res.json()) as { error: string }).toEqual({
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects requests with a wrong token", async () => {
|
||||||
|
const res = await request("/api/v1/feeds", {
|
||||||
|
headers: { Authorization: "Bearer nope" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a valid Bearer token", async () => {
|
||||||
|
const res = await request("/api/v1/feeds", { headers: authHeaders });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts proxy auth headers", async () => {
|
||||||
|
const proxyApp = new Hono();
|
||||||
|
proxyApp.route("/api", apiApp);
|
||||||
|
const proxyEnv = {
|
||||||
|
...createMockEnv(),
|
||||||
|
PROXY_TRUSTED_IPS: "10.0.0.1",
|
||||||
|
PROXY_AUTH_SECRET: "proxy-secret",
|
||||||
|
} as unknown as Env;
|
||||||
|
const res = await proxyApp.request(
|
||||||
|
"/api/v1/feeds",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"CF-Connecting-IP": "10.0.0.1",
|
||||||
|
"X-Auth-Proxy-Secret": "proxy-secret",
|
||||||
|
"Remote-User": "alice",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proxyEnv,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Feeds CRUD", () => {
|
||||||
|
it("creates, reads, lists, updates and deletes a feed", async () => {
|
||||||
|
// Create
|
||||||
|
const createRes = await request("/api/v1/feeds", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "Daily Digest",
|
||||||
|
description: "news",
|
||||||
|
allowedSenders: ["News@Example.com"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
const created = (await createRes.json()) as {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
allowedSenders: string[];
|
||||||
|
emailAddress: string;
|
||||||
|
rssUrl: string;
|
||||||
|
atomUrl: string;
|
||||||
|
emailCount: number;
|
||||||
|
};
|
||||||
|
expect(created.title).toBe("Daily Digest");
|
||||||
|
// senders are normalized to lowercase
|
||||||
|
expect(created.allowedSenders).toEqual(["news@example.com"]);
|
||||||
|
expect(created.emailCount).toBe(0);
|
||||||
|
expect(created.rssUrl).toContain(`/rss/${created.id}`);
|
||||||
|
|
||||||
|
// Get
|
||||||
|
const getRes = await request(`/api/v1/feeds/${created.id}`, {
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
expect((await getRes.json()) as { id: string }).toMatchObject({
|
||||||
|
id: created.id,
|
||||||
|
title: "Daily Digest",
|
||||||
|
});
|
||||||
|
|
||||||
|
// List
|
||||||
|
const listRes = await request("/api/v1/feeds", { headers: authHeaders });
|
||||||
|
const list = (await listRes.json()) as { feeds: { id: string }[] };
|
||||||
|
expect(list.feeds.map((f) => f.id)).toContain(created.id);
|
||||||
|
|
||||||
|
// Update
|
||||||
|
const patchRes = await request(`/api/v1/feeds/${created.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Renamed" }),
|
||||||
|
});
|
||||||
|
expect(patchRes.status).toBe(200);
|
||||||
|
expect((await patchRes.json()) as { title: string }).toMatchObject({
|
||||||
|
title: "Renamed",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
const delRes = await request(`/api/v1/feeds/${created.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
expect(delRes.status).toBe(200);
|
||||||
|
expect((await delRes.json()) as { ok: boolean }).toEqual({ ok: true });
|
||||||
|
|
||||||
|
// Gone from the list
|
||||||
|
const after = await request("/api/v1/feeds", { headers: authHeaders });
|
||||||
|
const afterList = (await after.json()) as { feeds: { id: string }[] };
|
||||||
|
expect(afterList.feeds.map((f) => f.id)).not.toContain(created.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for an invalid create body", async () => {
|
||||||
|
const res = await request("/api/v1/feeds", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect((await res.json()) as { error: string }).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when getting a missing feed", async () => {
|
||||||
|
const res = await request("/api/v1/feeds/does-not-exist", {
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when deleting a missing feed", async () => {
|
||||||
|
const res = await request("/api/v1/feeds/does-not-exist", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when updating a missing feed", async () => {
|
||||||
|
const res = await request("/api/v1/feeds/does-not-exist", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "x" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Emails", () => {
|
||||||
|
it("lists, reads and deletes an email", async () => {
|
||||||
|
const feedId = await createFeed();
|
||||||
|
|
||||||
|
// Seed an email directly into KV (mirrors storeEmail's key shape).
|
||||||
|
const receivedAt = 1737000000000;
|
||||||
|
const key = `feed:${feedId}:email:${receivedAt}`;
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
subject: "Hello",
|
||||||
|
from: "news@example.com",
|
||||||
|
content: "<p>hi</p>",
|
||||||
|
receivedAt,
|
||||||
|
headers: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${feedId}:metadata`,
|
||||||
|
JSON.stringify({
|
||||||
|
emails: [{ key, subject: "Hello", receivedAt }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// List
|
||||||
|
const listRes = await request(`/api/v1/feeds/${feedId}/emails`, {
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
expect(listRes.status).toBe(200);
|
||||||
|
const list = (await listRes.json()) as {
|
||||||
|
emails: { entryId: number; subject: string }[];
|
||||||
|
};
|
||||||
|
expect(list.emails).toHaveLength(1);
|
||||||
|
expect(list.emails[0]).toMatchObject({
|
||||||
|
entryId: receivedAt,
|
||||||
|
subject: "Hello",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single
|
||||||
|
const getRes = await request(
|
||||||
|
`/api/v1/feeds/${feedId}/emails/${receivedAt}`,
|
||||||
|
{ headers: authHeaders },
|
||||||
|
);
|
||||||
|
expect(getRes.status).toBe(200);
|
||||||
|
expect((await getRes.json()) as { content: string }).toMatchObject({
|
||||||
|
from: "news@example.com",
|
||||||
|
content: "<p>hi</p>",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
const delRes = await request(
|
||||||
|
`/api/v1/feeds/${feedId}/emails/${receivedAt}`,
|
||||||
|
{ method: "DELETE", headers: authHeaders },
|
||||||
|
);
|
||||||
|
expect(delRes.status).toBe(200);
|
||||||
|
expect(await mockEnv.EMAIL_STORAGE.get(key)).toBeNull();
|
||||||
|
|
||||||
|
// Gone
|
||||||
|
const after = await request(
|
||||||
|
`/api/v1/feeds/${feedId}/emails/${receivedAt}`,
|
||||||
|
{ headers: authHeaders },
|
||||||
|
);
|
||||||
|
expect(after.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 listing emails for a missing feed", async () => {
|
||||||
|
const res = await request("/api/v1/feeds/missing/emails", {
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Stats", () => {
|
||||||
|
it("returns monitoring counters without a token (public)", async () => {
|
||||||
|
await createFeed();
|
||||||
|
const res = await request("/api/v1/stats");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||||
|
const stats = (await res.json()) as {
|
||||||
|
feeds_created: number;
|
||||||
|
active_feeds: number;
|
||||||
|
attachments_enabled: boolean;
|
||||||
|
};
|
||||||
|
expect(stats.feeds_created).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(stats.active_feeds).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(typeof stats.attachments_enabled).toBe("boolean");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OpenAPI document", () => {
|
||||||
|
it("serves a public OpenAPI 3.1 spec", async () => {
|
||||||
|
const res = await request("/api/openapi.json");
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const doc = (await res.json()) as {
|
||||||
|
openapi: string;
|
||||||
|
paths: Record<string, { get?: { security?: unknown[] } }>;
|
||||||
|
};
|
||||||
|
expect(doc.openapi).toBe("3.1.0");
|
||||||
|
expect(doc.paths).toHaveProperty("/v1/feeds");
|
||||||
|
expect(doc.paths).toHaveProperty("/v1/feeds/{feedId}");
|
||||||
|
expect(doc.paths).toHaveProperty("/v1/stats");
|
||||||
|
// Feed routes are secured; stats is public.
|
||||||
|
expect(doc.paths["/v1/feeds"].get?.security).toBeTruthy();
|
||||||
|
expect(doc.paths["/v1/stats"].get?.security).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
import { Scalar } from "@scalar/hono-api-reference";
|
||||||
|
import { Env, FeedConfig } from "../../types";
|
||||||
|
import { apiAuthMiddleware } from "../../infrastructure/auth";
|
||||||
|
import {
|
||||||
|
createFeedRecord,
|
||||||
|
editFeed,
|
||||||
|
deleteFeedRecord,
|
||||||
|
} from "../../application/feed-service";
|
||||||
|
import { deleteAttachmentsForEmails } from "../../application/feed-cleanup";
|
||||||
|
import { waitUntilSafe } from "../../infrastructure/worker";
|
||||||
|
import { FeedRepository } from "../../infrastructure/feed-repository";
|
||||||
|
import { FeedId } from "../../domain/value-objects/feed-id";
|
||||||
|
import { getStats } from "../../application/stats";
|
||||||
|
import {
|
||||||
|
feedEmailAddress,
|
||||||
|
feedRssUrl,
|
||||||
|
feedAtomUrl,
|
||||||
|
} from "../../infrastructure/urls";
|
||||||
|
import {
|
||||||
|
ErrorSchema,
|
||||||
|
FeedIdParam,
|
||||||
|
EntryIdParam,
|
||||||
|
FeedCreateSchema,
|
||||||
|
FeedUpdateSchema,
|
||||||
|
FeedSchema,
|
||||||
|
FeedListSchema,
|
||||||
|
EmailListSchema,
|
||||||
|
EmailSchema,
|
||||||
|
StatsSchema,
|
||||||
|
} from "./schemas";
|
||||||
|
|
||||||
|
type AppEnv = { Bindings: Env };
|
||||||
|
|
||||||
|
const OkSchema = z.object({ ok: z.boolean() }).openapi("Ok");
|
||||||
|
|
||||||
|
const jsonContent = <T>(schema: T, description: string) => ({
|
||||||
|
content: { "application/json": { schema } },
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bearer = [{ bearerAuth: [] }];
|
||||||
|
|
||||||
|
function normalizeSenders(senders?: string[]): string[] | undefined {
|
||||||
|
return senders?.map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFeed(
|
||||||
|
id: string,
|
||||||
|
config: FeedConfig,
|
||||||
|
emailCount: number,
|
||||||
|
env: Env,
|
||||||
|
): z.infer<typeof FeedSchema> {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: config.title,
|
||||||
|
description: config.description,
|
||||||
|
language: config.language,
|
||||||
|
allowedSenders: config.allowed_senders ?? [],
|
||||||
|
blockedSenders: config.blocked_senders ?? [],
|
||||||
|
createdAt: config.created_at,
|
||||||
|
updatedAt: config.updated_at,
|
||||||
|
expiresAt: config.expires_at,
|
||||||
|
emailCount,
|
||||||
|
emailAddress: feedEmailAddress(id, env),
|
||||||
|
rssUrl: feedRssUrl(id, env),
|
||||||
|
atomUrl: feedAtomUrl(id, env),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiApp = new OpenAPIHono<AppEnv>({
|
||||||
|
defaultHook: (result, c) => {
|
||||||
|
if (!result.success) {
|
||||||
|
const message = result.error.issues
|
||||||
|
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||||
|
.join("; ");
|
||||||
|
return c.json({ error: `Validation failed: ${message}` }, 400);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token auth on the feed/email routes. The spec, docs, and /v1/stats stay public.
|
||||||
|
apiApp.use("/v1/feeds", apiAuthMiddleware);
|
||||||
|
apiApp.use("/v1/feeds/*", apiAuthMiddleware);
|
||||||
|
|
||||||
|
// Public monitoring stats — readable from any origin (landing page, embeds).
|
||||||
|
apiApp.use("/v1/stats", cors({ origin: "*" }));
|
||||||
|
|
||||||
|
apiApp.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
description: "Use the admin password as the bearer token.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Feeds ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
apiApp.openapi(
|
||||||
|
createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/v1/feeds",
|
||||||
|
tags: ["Feeds"],
|
||||||
|
summary: "List all feeds",
|
||||||
|
security: bearer,
|
||||||
|
responses: {
|
||||||
|
200: jsonContent(FeedListSchema, "The list of feeds"),
|
||||||
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const env = c.env;
|
||||||
|
const feeds = await FeedRepository.from(env).listFeeds();
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
feeds: feeds.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
title: f.title,
|
||||||
|
description: f.description,
|
||||||
|
expiresAt: f.expires_at,
|
||||||
|
emailAddress: feedEmailAddress(f.id, env),
|
||||||
|
rssUrl: feedRssUrl(f.id, env),
|
||||||
|
atomUrl: feedAtomUrl(f.id, env),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
apiApp.openapi(
|
||||||
|
createRoute({
|
||||||
|
method: "post",
|
||||||
|
path: "/v1/feeds",
|
||||||
|
tags: ["Feeds"],
|
||||||
|
summary: "Create a feed",
|
||||||
|
security: bearer,
|
||||||
|
request: {
|
||||||
|
body: { content: { "application/json": { schema: FeedCreateSchema } } },
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
201: jsonContent(FeedSchema, "The created feed"),
|
||||||
|
400: jsonContent(ErrorSchema, "Invalid request body"),
|
||||||
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const env = c.env;
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const { feedId, config } = await createFeedRecord(env, {
|
||||||
|
title: body.title,
|
||||||
|
description: body.description,
|
||||||
|
language: body.language,
|
||||||
|
allowedSenders: normalizeSenders(body.allowedSenders) ?? [],
|
||||||
|
blockedSenders: normalizeSenders(body.blockedSenders) ?? [],
|
||||||
|
lifetimeHours: body.lifetimeHours,
|
||||||
|
});
|
||||||
|
return c.json(toFeed(feedId, config, 0, env), 201);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
apiApp.openapi(
|
||||||
|
createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/v1/feeds/{feedId}",
|
||||||
|
tags: ["Feeds"],
|
||||||
|
summary: "Get a feed",
|
||||||
|
security: bearer,
|
||||||
|
request: { params: FeedIdParam },
|
||||||
|
responses: {
|
||||||
|
200: jsonContent(FeedSchema, "The feed"),
|
||||||
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||||
|
404: jsonContent(ErrorSchema, "Feed not found"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const env = c.env;
|
||||||
|
const { feedId } = c.req.valid("param");
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
const id = FeedId.unchecked(feedId);
|
||||||
|
const config = await repo.getConfig(id);
|
||||||
|
if (!config) return c.json({ error: "Feed not found" }, 404);
|
||||||
|
const metadata = await repo.getMetadata(id);
|
||||||
|
return c.json(
|
||||||
|
toFeed(feedId, config, metadata?.emails.length ?? 0, env),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
apiApp.openapi(
|
||||||
|
createRoute({
|
||||||
|
method: "patch",
|
||||||
|
path: "/v1/feeds/{feedId}",
|
||||||
|
tags: ["Feeds"],
|
||||||
|
summary: "Update a feed",
|
||||||
|
security: bearer,
|
||||||
|
request: {
|
||||||
|
params: FeedIdParam,
|
||||||
|
body: { content: { "application/json": { schema: FeedUpdateSchema } } },
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: jsonContent(FeedSchema, "The updated feed"),
|
||||||
|
400: jsonContent(ErrorSchema, "Invalid request body"),
|
||||||
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||||
|
404: jsonContent(ErrorSchema, "Feed not found"),
|
||||||
|
409: jsonContent(ErrorSchema, "Feed has expired"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const env = c.env;
|
||||||
|
const { feedId } = c.req.valid("param");
|
||||||
|
const id = FeedId.unchecked(feedId);
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
const result = await editFeed(env, id, {
|
||||||
|
title: body.title,
|
||||||
|
description: body.description,
|
||||||
|
language: body.language,
|
||||||
|
allowedSenders: normalizeSenders(body.allowedSenders),
|
||||||
|
blockedSenders: normalizeSenders(body.blockedSenders),
|
||||||
|
lifetimeHours: body.lifetimeHours,
|
||||||
|
});
|
||||||
|
if (result.status === "not_found")
|
||||||
|
return c.json({ error: "Feed not found" }, 404);
|
||||||
|
if (result.status === "expired")
|
||||||
|
return c.json({ error: "Feed has expired and cannot be modified" }, 409);
|
||||||
|
const metadata = await FeedRepository.from(env).getMetadata(id);
|
||||||
|
return c.json(
|
||||||
|
toFeed(feedId, result.config, metadata?.emails.length ?? 0, env),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
apiApp.openapi(
|
||||||
|
createRoute({
|
||||||
|
method: "delete",
|
||||||
|
path: "/v1/feeds/{feedId}",
|
||||||
|
tags: ["Feeds"],
|
||||||
|
summary: "Delete a feed",
|
||||||
|
security: bearer,
|
||||||
|
request: { params: FeedIdParam },
|
||||||
|
responses: {
|
||||||
|
200: jsonContent(OkSchema, "The feed was deleted"),
|
||||||
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||||
|
404: jsonContent(ErrorSchema, "Feed not found"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const env = c.env;
|
||||||
|
const { feedId } = c.req.valid("param");
|
||||||
|
const removed = await deleteFeedRecord(env, FeedId.unchecked(feedId), (p) =>
|
||||||
|
waitUntilSafe(c, p),
|
||||||
|
);
|
||||||
|
if (!removed) return c.json({ error: "Feed not found" }, 404);
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Emails ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
apiApp.openapi(
|
||||||
|
createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/v1/feeds/{feedId}/emails",
|
||||||
|
tags: ["Emails"],
|
||||||
|
summary: "List a feed's emails",
|
||||||
|
security: bearer,
|
||||||
|
request: { params: FeedIdParam },
|
||||||
|
responses: {
|
||||||
|
200: jsonContent(EmailListSchema, "The feed's emails"),
|
||||||
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||||
|
404: jsonContent(ErrorSchema, "Feed not found"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const env = c.env;
|
||||||
|
const { feedId } = c.req.valid("param");
|
||||||
|
const metadata = await FeedRepository.from(env).getMetadata(
|
||||||
|
FeedId.unchecked(feedId),
|
||||||
|
);
|
||||||
|
if (!metadata) return c.json({ error: "Feed not found" }, 404);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
emails: metadata.emails.map((e) => ({
|
||||||
|
entryId: e.receivedAt,
|
||||||
|
subject: e.subject,
|
||||||
|
receivedAt: e.receivedAt,
|
||||||
|
size: e.size,
|
||||||
|
attachmentIds: e.attachmentIds,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
apiApp.openapi(
|
||||||
|
createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/v1/feeds/{feedId}/emails/{entryId}",
|
||||||
|
tags: ["Emails"],
|
||||||
|
summary: "Get a single email",
|
||||||
|
security: bearer,
|
||||||
|
request: { params: EntryIdParam },
|
||||||
|
responses: {
|
||||||
|
200: jsonContent(EmailSchema, "The email"),
|
||||||
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||||
|
404: jsonContent(ErrorSchema, "Email not found"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const env = c.env;
|
||||||
|
const { feedId, entryId } = c.req.valid("param");
|
||||||
|
const receivedAt = parseInt(entryId, 10);
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
const metadata = await repo.getMetadata(FeedId.unchecked(feedId));
|
||||||
|
const metaEntry = metadata?.emails.find((e) => e.receivedAt === receivedAt);
|
||||||
|
if (!metaEntry) return c.json({ error: "Email not found" }, 404);
|
||||||
|
const data = await repo.getEmail(metaEntry.key);
|
||||||
|
if (!data) return c.json({ error: "Email not found" }, 404);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
entryId: receivedAt,
|
||||||
|
subject: data.subject,
|
||||||
|
from: data.from,
|
||||||
|
receivedAt: data.receivedAt,
|
||||||
|
content: data.content,
|
||||||
|
attachments: (data.attachments ?? [])
|
||||||
|
.filter((a) => !a.inline)
|
||||||
|
.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
filename: a.filename,
|
||||||
|
contentType: a.contentType,
|
||||||
|
size: a.size,
|
||||||
|
url: `/files/${a.id}/${encodeURIComponent(a.filename)}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
apiApp.openapi(
|
||||||
|
createRoute({
|
||||||
|
method: "delete",
|
||||||
|
path: "/v1/feeds/{feedId}/emails/{entryId}",
|
||||||
|
tags: ["Emails"],
|
||||||
|
summary: "Delete a single email",
|
||||||
|
security: bearer,
|
||||||
|
request: { params: EntryIdParam },
|
||||||
|
responses: {
|
||||||
|
200: jsonContent(OkSchema, "The email was deleted"),
|
||||||
|
401: jsonContent(ErrorSchema, "Unauthorized"),
|
||||||
|
404: jsonContent(ErrorSchema, "Email not found"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const env = c.env;
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
const { feedId, entryId } = c.req.valid("param");
|
||||||
|
const receivedAt = parseInt(entryId, 10);
|
||||||
|
const feed = await repo.load(FeedId.unchecked(feedId));
|
||||||
|
const metaEntry = feed?.emails.find((e) => e.receivedAt === receivedAt);
|
||||||
|
if (!feed || !metaEntry) return c.json({ error: "Email not found" }, 404);
|
||||||
|
|
||||||
|
await repo.deleteEmail(metaEntry.key);
|
||||||
|
const { removed } = feed.removeEmails([metaEntry.key]);
|
||||||
|
await deleteAttachmentsForEmails(env, removed, [metaEntry.key]);
|
||||||
|
await repo.saveMetadata(feed);
|
||||||
|
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Stats ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
apiApp.openapi(
|
||||||
|
createRoute({
|
||||||
|
method: "get",
|
||||||
|
path: "/v1/stats",
|
||||||
|
tags: ["Stats"],
|
||||||
|
summary: "Read monitoring counters (public)",
|
||||||
|
responses: {
|
||||||
|
200: jsonContent(StatsSchema, "Monitoring counters"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
return c.json(await getStats(c.env), 200);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── OpenAPI document + docs (public) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
apiApp.doc31("/openapi.json", {
|
||||||
|
openapi: "3.1.0",
|
||||||
|
info: {
|
||||||
|
title: "kill-the-news API",
|
||||||
|
version: "1.0.0",
|
||||||
|
description:
|
||||||
|
"REST API for managing email-to-RSS feeds, their emails, and monitoring counters.",
|
||||||
|
},
|
||||||
|
servers: [{ url: "/api" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
apiApp.get(
|
||||||
|
"/docs",
|
||||||
|
Scalar({
|
||||||
|
url: "/api/openapi.json",
|
||||||
|
pageTitle: "kill-the-news API",
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { z } from "@hono/zod-openapi";
|
||||||
|
|
||||||
|
// ── Shared ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ErrorSchema = z
|
||||||
|
.object({
|
||||||
|
error: z.string().openapi({ example: "Feed not found" }),
|
||||||
|
})
|
||||||
|
.openapi("Error");
|
||||||
|
|
||||||
|
export const FeedIdParam = z.object({
|
||||||
|
feedId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.openapi({
|
||||||
|
param: { name: "feedId", in: "path" },
|
||||||
|
example: "happy-otter-1234",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EntryIdParam = FeedIdParam.extend({
|
||||||
|
entryId: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d+$/, "entryId must be the email's receivedAt timestamp")
|
||||||
|
.openapi({
|
||||||
|
param: { name: "entryId", in: "path" },
|
||||||
|
example: "1737000000000",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Feeds ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const FeedCreateSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().min(1).openapi({ example: "Daily Tech Digest" }),
|
||||||
|
description: z.string().optional(),
|
||||||
|
language: z.string().optional().default("en"),
|
||||||
|
allowedSenders: z.array(z.string()).optional().default([]),
|
||||||
|
blockedSenders: z.array(z.string()).optional().default([]),
|
||||||
|
lifetimeHours: z.number().int().positive().optional().openapi({
|
||||||
|
description:
|
||||||
|
"Hours until the feed expires. Ignored when the server enforces a fixed FEED_TTL_HOURS.",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.openapi("FeedCreate");
|
||||||
|
|
||||||
|
export const FeedUpdateSchema = z
|
||||||
|
.object({
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
language: z.string().optional(),
|
||||||
|
allowedSenders: z.array(z.string()).optional(),
|
||||||
|
blockedSenders: z.array(z.string()).optional(),
|
||||||
|
lifetimeHours: z.number().int().positive().optional().openapi({
|
||||||
|
description: "Reset the feed's lifetime to this many hours from now.",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.openapi("FeedUpdate");
|
||||||
|
|
||||||
|
export const FeedSummarySchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
expiresAt: z.number().optional(),
|
||||||
|
emailAddress: z.string(),
|
||||||
|
rssUrl: z.string(),
|
||||||
|
atomUrl: z.string(),
|
||||||
|
})
|
||||||
|
.openapi("FeedSummary");
|
||||||
|
|
||||||
|
export const FeedListSchema = z
|
||||||
|
.object({ feeds: z.array(FeedSummarySchema) })
|
||||||
|
.openapi("FeedList");
|
||||||
|
|
||||||
|
export const FeedSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
language: z.string(),
|
||||||
|
allowedSenders: z.array(z.string()),
|
||||||
|
blockedSenders: z.array(z.string()),
|
||||||
|
createdAt: z.number(),
|
||||||
|
updatedAt: z.number().optional(),
|
||||||
|
expiresAt: z.number().optional(),
|
||||||
|
emailCount: z.number(),
|
||||||
|
emailAddress: z.string(),
|
||||||
|
rssUrl: z.string(),
|
||||||
|
atomUrl: z.string(),
|
||||||
|
})
|
||||||
|
.openapi("Feed");
|
||||||
|
|
||||||
|
// ── Emails ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const EmailSummarySchema = z
|
||||||
|
.object({
|
||||||
|
entryId: z.number().openapi({
|
||||||
|
description: "Email receivedAt timestamp; used as the path id.",
|
||||||
|
}),
|
||||||
|
subject: z.string(),
|
||||||
|
receivedAt: z.number(),
|
||||||
|
size: z.number().optional(),
|
||||||
|
attachmentIds: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.openapi("EmailSummary");
|
||||||
|
|
||||||
|
export const EmailListSchema = z
|
||||||
|
.object({ emails: z.array(EmailSummarySchema) })
|
||||||
|
.openapi("EmailList");
|
||||||
|
|
||||||
|
export const AttachmentSchema = z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
filename: z.string(),
|
||||||
|
contentType: z.string(),
|
||||||
|
size: z.number(),
|
||||||
|
url: z.string(),
|
||||||
|
})
|
||||||
|
.openapi("Attachment");
|
||||||
|
|
||||||
|
export const EmailSchema = z
|
||||||
|
.object({
|
||||||
|
entryId: z.number(),
|
||||||
|
subject: z.string(),
|
||||||
|
from: z.string(),
|
||||||
|
receivedAt: z.number(),
|
||||||
|
content: z.string(),
|
||||||
|
attachments: z.array(AttachmentSchema),
|
||||||
|
})
|
||||||
|
.openapi("Email");
|
||||||
|
|
||||||
|
// ── Stats ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const StatsSchema = z
|
||||||
|
.object({
|
||||||
|
feeds_created: z.number(),
|
||||||
|
feeds_deleted: z.number(),
|
||||||
|
emails_received: z.number(),
|
||||||
|
emails_rejected: z.number(),
|
||||||
|
unsubscribes_sent: z.number(),
|
||||||
|
active_feeds: z.number(),
|
||||||
|
websub_subscriptions_active: z.number(),
|
||||||
|
attachments_enabled: z.boolean(),
|
||||||
|
last_email_at: z.string().optional(),
|
||||||
|
last_feed_created_at: z.string().optional(),
|
||||||
|
first_seen: z.string().optional(),
|
||||||
|
attachments_bytes: z.number().optional(),
|
||||||
|
attachments_count: z.number().optional(),
|
||||||
|
kv_bytes_estimated: z.number().optional(),
|
||||||
|
storage_scanned_at: z.string().optional(),
|
||||||
|
})
|
||||||
|
.openapi("Stats");
|
||||||
+9
-4
@@ -1,8 +1,10 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
import { generateAtomFeed } from "../utils/feed-generator";
|
import { generateAtomFeed } from "../infrastructure/feed-generator";
|
||||||
import { fetchFeedData } from "../utils/feed-fetcher";
|
import { fetchFeedData } from "../application/feed-fetcher";
|
||||||
import { baseUrl, feedAtomUrl } from "../utils/urls";
|
import { baseUrl, feedAtomUrl } from "../infrastructure/urls";
|
||||||
|
import { isExpired } from "../domain/feed";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
|
||||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
@@ -11,10 +13,13 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
return new Response("Feed ID is required", { status: 400 });
|
return new Response("Feed ID is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedData = await fetchFeedData(feedId, c.env);
|
const feedData = await fetchFeedData(FeedId.unchecked(feedId), c.env);
|
||||||
if (!feedData) {
|
if (!feedData) {
|
||||||
return new Response("Feed not found", { status: 404 });
|
return new Response("Feed not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
if (isExpired(feedData.feedConfig)) {
|
||||||
|
return new Response("Feed has expired", { status: 410 });
|
||||||
|
}
|
||||||
|
|
||||||
const base = baseUrl(c.env);
|
const base = baseUrl(c.env);
|
||||||
const selfUrl = new URL(c.req.url).origin + `/atom/${feedId}`;
|
const selfUrl = new URL(c.req.url).origin + `/atom/${feedId}`;
|
||||||
|
|||||||
@@ -13,15 +13,27 @@ function makeApp() {
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedFeed(env: ReturnType<typeof createMockEnv>) {
|
async function seedFeed(
|
||||||
|
env: ReturnType<typeof createMockEnv>,
|
||||||
|
attachments?: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
contentId?: string;
|
||||||
|
inline?: boolean;
|
||||||
|
}[],
|
||||||
|
content = "<p>Email body</p>",
|
||||||
|
) {
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
EMAIL_KEY,
|
EMAIL_KEY,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
subject: "Test Subject",
|
subject: "Test Subject",
|
||||||
from: "sender@example.com",
|
from: "sender@example.com",
|
||||||
content: "<p>Email body</p>",
|
content,
|
||||||
receivedAt: RECEIVED_AT,
|
receivedAt: RECEIVED_AT,
|
||||||
headers: {},
|
headers: {},
|
||||||
|
...(attachments ? { attachments } : {}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
@@ -97,6 +109,59 @@ describe("GET /entries/:feedId/:entryId", () => {
|
|||||||
expect(body).toContain("sender@example.com");
|
expect(body).toContain("sender@example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists attachments with download links when present", async () => {
|
||||||
|
await seedFeed(env, [
|
||||||
|
{
|
||||||
|
id: "att-123",
|
||||||
|
filename: "report final.pdf",
|
||||||
|
contentType: "application/pdf",
|
||||||
|
size: 2048,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("Attachments");
|
||||||
|
expect(body).toContain("report final.pdf");
|
||||||
|
expect(body).toContain(
|
||||||
|
`/files/att-123/${encodeURIComponent("report final.pdf")}`,
|
||||||
|
);
|
||||||
|
expect(body).toContain("2.0 KB");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders inline images in place and omits them from the attachments list", async () => {
|
||||||
|
await seedFeed(
|
||||||
|
env,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "img-1",
|
||||||
|
filename: "logo.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
size: 512,
|
||||||
|
contentId: "logo123",
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'<p>Body</p><img src="cid:logo123"/>',
|
||||||
|
);
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
|
||||||
|
const body = await res.text();
|
||||||
|
// The cid: ref is rewritten to the stored file URL (rendered in place)…
|
||||||
|
expect(body).toContain('src="/files/img-1/logo.png"');
|
||||||
|
expect(body).not.toContain("cid:logo123");
|
||||||
|
// …and the image is not listed as a downloadable attachment.
|
||||||
|
expect(body).not.toContain("Attachments");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render an attachments section when there are none", 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).not.toContain("Attachments");
|
||||||
|
});
|
||||||
|
|
||||||
it("sets Content-Security-Policy header", async () => {
|
it("sets Content-Security-Policy header", async () => {
|
||||||
await seedFeed(env);
|
await seedFeed(env);
|
||||||
const app = makeApp();
|
const app = makeApp();
|
||||||
|
|||||||
+45
-33
@@ -1,7 +1,12 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { html, raw } from "hono/html";
|
import { html, raw } from "hono/html";
|
||||||
import { Env, FeedMetadata, EmailData } from "../types";
|
import { Env } from "../types";
|
||||||
import { processEmailContent } from "../utils/html-processor";
|
import { processEmailContent } from "../infrastructure/html-processor";
|
||||||
|
import { formatBytes } from "../domain/format";
|
||||||
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import { isExpired } from "../domain/feed";
|
||||||
|
import entryCss from "../styles/entry.css";
|
||||||
|
|
||||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||||
const feedId = c.req.param("feedId");
|
const feedId = c.req.param("feedId");
|
||||||
@@ -11,15 +16,19 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
return new Response("Not Found", { status: 404 });
|
return new Response("Not Found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailStorage = c.env.EMAIL_STORAGE;
|
const repo = FeedRepository.from(c.env);
|
||||||
|
const id = FeedId.unchecked(feedId);
|
||||||
|
|
||||||
const feedMetadata = (await emailStorage.get(
|
const [feedMetadata, feedConfig] = await Promise.all([
|
||||||
`feed:${feedId}:metadata`,
|
repo.getMetadata(id),
|
||||||
"json",
|
repo.getConfig(id),
|
||||||
)) as FeedMetadata | null;
|
]);
|
||||||
if (!feedMetadata) {
|
if (!feedMetadata) {
|
||||||
return new Response("Feed not found", { status: 404 });
|
return new Response("Feed not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
if (feedConfig && isExpired(feedConfig)) {
|
||||||
|
return new Response("Feed has expired", { status: 410 });
|
||||||
|
}
|
||||||
|
|
||||||
const metaEntry = feedMetadata.emails.find(
|
const metaEntry = feedMetadata.emails.find(
|
||||||
(e) => e.receivedAt === receivedAt,
|
(e) => e.receivedAt === receivedAt,
|
||||||
@@ -28,10 +37,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
return new Response("Entry not found", { status: 404 });
|
return new Response("Entry not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailData = (await emailStorage.get(
|
const emailData = await repo.getEmail(metaEntry.key);
|
||||||
metaEntry.key,
|
|
||||||
"json",
|
|
||||||
)) as EmailData | null;
|
|
||||||
if (!emailData) {
|
if (!emailData) {
|
||||||
return new Response("Entry not found", { status: 404 });
|
return new Response("Entry not found", { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -41,6 +47,28 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
"default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'",
|
"default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Inline images render in place (cid: refs are rewritten by processEmailContent);
|
||||||
|
// only genuine, downloadable attachments belong in the list below.
|
||||||
|
const attachments = (emailData.attachments ?? []).filter((a) => !a.inline);
|
||||||
|
const attachmentsSection = attachments.length
|
||||||
|
? html`<section class="attachments">
|
||||||
|
<h2>Attachments</h2>
|
||||||
|
<ul>
|
||||||
|
${attachments.map(
|
||||||
|
(a) =>
|
||||||
|
html`<li>
|
||||||
|
<a
|
||||||
|
href="/files/${a.id}/${encodeURIComponent(a.filename)}"
|
||||||
|
download
|
||||||
|
>${a.filename}</a
|
||||||
|
>
|
||||||
|
<span class="size">${formatBytes(a.size)}</span>
|
||||||
|
</li>`,
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</section>`
|
||||||
|
: "";
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
html`<!DOCTYPE html>
|
html`<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -51,28 +79,9 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
content="width=device-width, initial-scale=1.0"
|
content="width=device-width, initial-scale=1.0"
|
||||||
/>
|
/>
|
||||||
<title>${emailData.subject}</title>
|
<title>${emailData.subject}</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<style>
|
<style>
|
||||||
body {
|
${raw(entryCss)}
|
||||||
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -84,8 +93,11 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
<dd>${new Date(emailData.receivedAt).toUTCString()}</dd>
|
<dd>${new Date(emailData.receivedAt).toUTCString()}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
${raw(processEmailContent(emailData.content))}
|
${raw(
|
||||||
|
processEmailContent(emailData.content, emailData.attachments),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
${attachmentsSection}
|
||||||
</body>
|
</body>
|
||||||
</html>`,
|
</html>`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import worker from "../index";
|
||||||
|
import { createMockEnv } from "../test/setup";
|
||||||
|
import type { Env } from "../types";
|
||||||
|
|
||||||
|
const iconKey = (domain: string) => `icon:${domain}`;
|
||||||
|
|
||||||
|
function req(path: string): Request {
|
||||||
|
return new Request(`https://test.getmynews.app${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 9, 8, 7]);
|
||||||
|
|
||||||
|
function toBase64(bytes: Uint8Array): string {
|
||||||
|
return btoa(String.fromCharCode(...bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project favicon", () => {
|
||||||
|
it("serves an SVG favicon at /favicon.svg", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const res = await worker.fetch(req("/favicon.svg"), env);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||||
|
expect(res.headers.get("Cache-Control")).toContain("max-age");
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("<svg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the same icon at /favicon.ico", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const res = await worker.fetch(req("/favicon.ico"), env);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("<svg");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("per-feed favicon", () => {
|
||||||
|
it("serves the cached domain icon when available", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
"feed:abc:metadata",
|
||||||
|
JSON.stringify({ emails: [], iconDomain: "github.com" }),
|
||||||
|
);
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
iconKey("github.com"),
|
||||||
|
JSON.stringify({ data: toBase64(PNG), contentType: "image/png" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await worker.fetch(req("/favicon/abc"), env);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toBe("image/png");
|
||||||
|
expect(new Uint8Array(await res.arrayBuffer())).toEqual(PNG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the project SVG when the feed has no icon domain", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
"feed:abc:metadata",
|
||||||
|
JSON.stringify({ emails: [] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await worker.fetch(req("/favicon/abc"), env);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||||
|
expect(await res.text()).toContain("<svg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the project SVG for a negative cache entry", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
"feed:abc:metadata",
|
||||||
|
JSON.stringify({ emails: [], iconDomain: "nope.test" }),
|
||||||
|
);
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
iconKey("nope.test"),
|
||||||
|
JSON.stringify({ data: null, contentType: "" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await worker.fetch(req("/favicon/abc"), env);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the project SVG for an unknown feed", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const res = await worker.fetch(req("/favicon/missing"), env);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toMatch(/^image\/svg\+xml/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
import { Env } from "../types";
|
||||||
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import {
|
||||||
|
cacheFaviconForDomain,
|
||||||
|
getCachedIcon,
|
||||||
|
} from "../infrastructure/favicon-fetcher";
|
||||||
|
|
||||||
|
export const FAVICON_PATH = "/favicon.svg";
|
||||||
|
|
||||||
|
// Project favicon — reuses the header's envelope logo (brand orange #f6821f),
|
||||||
|
// rendered as a white envelope on a rounded orange square for legibility at 16px.
|
||||||
|
export const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#f6821f"/>
|
||||||
|
<g fill="none" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M7 9h18c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H7c-1.1 0-2-.9-2-2V11c0-1.1.9-2 2-2z"/>
|
||||||
|
<polyline points="27,11 16,18.5 5,11"/>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
function projectFavicon(): Response {
|
||||||
|
return new Response(FAVICON_SVG, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/svg+xml; charset=utf-8",
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handle(_c: Context<{ Bindings: Env }>): Response {
|
||||||
|
return projectFavicon();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-feed favicon. Resolves the feed's most recent sender domain and serves
|
||||||
|
* its cached icon; falls back to the project icon for any unresolved case
|
||||||
|
* (no domain, cache miss, or negative cache entry).
|
||||||
|
*/
|
||||||
|
export async function handleFeedFavicon(
|
||||||
|
c: Context<{ Bindings: Env }>,
|
||||||
|
): Promise<Response> {
|
||||||
|
const env = c.env;
|
||||||
|
const feedId = c.req.param("feedId");
|
||||||
|
if (!feedId) return projectFavicon();
|
||||||
|
|
||||||
|
const metadata = await FeedRepository.from(env).getMetadata(
|
||||||
|
FeedId.unchecked(feedId),
|
||||||
|
);
|
||||||
|
const domain = metadata?.iconDomain;
|
||||||
|
if (!domain) return projectFavicon();
|
||||||
|
|
||||||
|
const icon = await getCachedIcon(domain, env);
|
||||||
|
if (icon) {
|
||||||
|
return new Response(icon.bytes, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": icon.contentType,
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known domain but nothing cached yet: warm the cache in the background and
|
||||||
|
// serve the fallback for now.
|
||||||
|
try {
|
||||||
|
c.executionCtx.waitUntil(cacheFaviconForDomain(domain, env));
|
||||||
|
} catch {
|
||||||
|
// No ExecutionContext (e.g. tests) — fallback is served regardless.
|
||||||
|
}
|
||||||
|
return projectFavicon();
|
||||||
|
}
|
||||||
+4
-2
@@ -1,8 +1,10 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
|
import { getAttachmentBucket } from "../infrastructure/attachments";
|
||||||
|
|
||||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||||
if (!c.env.ATTACHMENT_BUCKET) {
|
const bucket = getAttachmentBucket(c.env);
|
||||||
|
if (!bucket) {
|
||||||
return new Response("Attachment storage not configured", { status: 404 });
|
return new Response("Attachment storage not configured", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const object = await c.env.ATTACHMENT_BUCKET.get(attachmentId);
|
const object = await bucket.get(attachmentId);
|
||||||
|
|
||||||
if (!object) {
|
if (!object) {
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
import { Env } from "../types";
|
||||||
|
import { getStats } from "../application/stats";
|
||||||
|
import { formatBytes } from "../domain/format";
|
||||||
|
import { R2_FREE_TIER_BYTES, KV_FREE_TIER_BYTES } from "../config/constants";
|
||||||
|
import { Layout } from "./admin/ui";
|
||||||
|
|
||||||
|
function formatDateTime(iso?: string): string {
|
||||||
|
if (!iso) return "Never";
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return "Never";
|
||||||
|
return date
|
||||||
|
.toISOString()
|
||||||
|
.replace("T", " ")
|
||||||
|
.replace(/\.\d+Z$/, " UTC");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(iso?: string): string {
|
||||||
|
if (!iso) return "Never";
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return "Never";
|
||||||
|
const diffMs = Date.now() - date.getTime();
|
||||||
|
if (diffMs < 0) return "just now";
|
||||||
|
const seconds = Math.floor(diffMs / 1000);
|
||||||
|
if (seconds < 60) return "just now";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(iso?: string): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
const diffMs = Date.now() - date.getTime();
|
||||||
|
if (diffMs < 0) return "—";
|
||||||
|
const minutes = Math.floor(diffMs / 60000);
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days} ${days === 1 ? "day" : "days"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierPercent(used: number, total: number): number {
|
||||||
|
if (total <= 0) return 0;
|
||||||
|
return Math.round((used / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytesOrDash(bytes?: number): string {
|
||||||
|
return bytes === undefined ? "—" : formatBytes(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tone = "success" | "danger";
|
||||||
|
|
||||||
|
type StatProps = {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
tone?: Tone;
|
||||||
|
title?: string;
|
||||||
|
time?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stat = ({ label, value, tone, title, time }: StatProps) => {
|
||||||
|
const valueClass = [
|
||||||
|
"stat-value",
|
||||||
|
time ? "stat-value-time" : "",
|
||||||
|
tone ? `stat-value-${tone}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
<div class="card stat-card" title={title}>
|
||||||
|
<span class={valueClass}>{value}</span>
|
||||||
|
<span class="stat-label">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||||
|
const stats = await getStats(c.env);
|
||||||
|
|
||||||
|
const netFeeds = stats.feeds_created - stats.feeds_deleted;
|
||||||
|
const totalEmails = stats.emails_received + stats.emails_rejected;
|
||||||
|
const acceptanceRate =
|
||||||
|
totalEmails > 0
|
||||||
|
? `${Math.round((stats.emails_received / totalEmails) * 100)}%`
|
||||||
|
: "—";
|
||||||
|
const avgEmailsPerFeed =
|
||||||
|
stats.feeds_created > 0
|
||||||
|
? (stats.emails_received / stats.feeds_created).toFixed(1)
|
||||||
|
: "—";
|
||||||
|
|
||||||
|
const kvPercent = tierPercent(
|
||||||
|
stats.kv_bytes_estimated ?? 0,
|
||||||
|
KV_FREE_TIER_BYTES,
|
||||||
|
);
|
||||||
|
const r2Percent = tierPercent(
|
||||||
|
stats.attachments_bytes ?? 0,
|
||||||
|
R2_FREE_TIER_BYTES,
|
||||||
|
);
|
||||||
|
|
||||||
|
return c.html(
|
||||||
|
<Layout title="Status" label="status">
|
||||||
|
<div class="container fade-in">
|
||||||
|
<div class="header-with-actions">
|
||||||
|
<div class="header-title">
|
||||||
|
<h1>kill-the-news</h1>
|
||||||
|
<p>Instance status & monitoring</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="/admin" class="button">
|
||||||
|
Go to admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card stat-hero">
|
||||||
|
<div class="stat-hero-main">
|
||||||
|
<span class="stat-hero-value">{stats.active_feeds}</span>
|
||||||
|
<span class="stat-hero-label">Active feeds</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-hero-aside">
|
||||||
|
<span class="stat-hero-aside-value">{stats.emails_received}</span>
|
||||||
|
<span class="stat-hero-aside-label">Emails received</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="stat-section">
|
||||||
|
<h2 class="stat-section-title">Feeds</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<Stat label="Feeds created" value={stats.feeds_created} />
|
||||||
|
<Stat
|
||||||
|
label="Feeds deleted"
|
||||||
|
value={stats.feeds_deleted}
|
||||||
|
tone="danger"
|
||||||
|
/>
|
||||||
|
<Stat label="Net feeds" value={netFeeds} />
|
||||||
|
<Stat label="Unsubscribes sent" value={stats.unsubscribes_sent} />
|
||||||
|
<Stat
|
||||||
|
label="Last feed created"
|
||||||
|
value={formatRelative(stats.last_feed_created_at)}
|
||||||
|
title={formatDateTime(stats.last_feed_created_at)}
|
||||||
|
time
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-section">
|
||||||
|
<h2 class="stat-section-title">Emails</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<Stat
|
||||||
|
label="Emails received"
|
||||||
|
value={stats.emails_received}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Emails rejected"
|
||||||
|
value={stats.emails_rejected}
|
||||||
|
tone="danger"
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Acceptance rate"
|
||||||
|
value={acceptanceRate}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<Stat label="Avg emails / feed" value={avgEmailsPerFeed} />
|
||||||
|
<Stat
|
||||||
|
label="Last email"
|
||||||
|
value={formatRelative(stats.last_email_at)}
|
||||||
|
title={formatDateTime(stats.last_email_at)}
|
||||||
|
time
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-section">
|
||||||
|
<h2 class="stat-section-title">Distribution</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<Stat
|
||||||
|
label="WebSub subscribers"
|
||||||
|
value={stats.websub_subscriptions_active}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-section">
|
||||||
|
<h2 class="stat-section-title">Storage</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<Stat
|
||||||
|
label="KV space used (est.)"
|
||||||
|
value={formatBytesOrDash(stats.kv_bytes_estimated)}
|
||||||
|
title={`${kvPercent}% of 1 GB free tier — estimate`}
|
||||||
|
tone={kvPercent >= 80 ? "danger" : undefined}
|
||||||
|
/>
|
||||||
|
{stats.attachments_enabled ? (
|
||||||
|
<>
|
||||||
|
<Stat
|
||||||
|
label="Attachments stored"
|
||||||
|
value={stats.attachments_count ?? "—"}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="R2 space used"
|
||||||
|
value={formatBytesOrDash(stats.attachments_bytes)}
|
||||||
|
title={`${r2Percent}% of 10 GB free tier`}
|
||||||
|
tone={r2Percent >= 80 ? "danger" : undefined}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Stat label="Attachments (R2)" value="Off" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-section">
|
||||||
|
<h2 class="stat-section-title">Instance</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<Stat label="Uptime" value={formatUptime(stats.first_seen)} time />
|
||||||
|
<Stat
|
||||||
|
label="Online since"
|
||||||
|
value={formatRelative(stats.first_seen)}
|
||||||
|
title={formatDateTime(stats.first_seen)}
|
||||||
|
time
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Layout>,
|
||||||
|
);
|
||||||
|
}
|
||||||
+7
-8
@@ -3,10 +3,12 @@ import { Env } from "../types";
|
|||||||
import {
|
import {
|
||||||
verifyAndStoreSubscription,
|
verifyAndStoreSubscription,
|
||||||
verifyAndDeleteSubscription,
|
verifyAndDeleteSubscription,
|
||||||
} from "../utils/websub";
|
} from "../infrastructure/websub";
|
||||||
import { waitUntilSafe } from "../utils/worker";
|
import { waitUntilSafe } from "../infrastructure/worker";
|
||||||
import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants";
|
import { DEFAULT_LEASE_SECONDS, MAX_LEASE_SECONDS } from "../config/constants";
|
||||||
import { feedTopicPattern } from "../utils/urls";
|
import { feedTopicPattern } from "../infrastructure/urls";
|
||||||
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
|
|
||||||
@@ -70,13 +72,10 @@ hubRouter.post("/", async (c) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const format = match[1] as "rss" | "atom";
|
const format = match[1] as "rss" | "atom";
|
||||||
const feedId = match[2];
|
const feedId = FeedId.unchecked(match[2]);
|
||||||
|
|
||||||
// Verify the feed exists before accepting any subscription
|
// Verify the feed exists before accepting any subscription
|
||||||
const feedConfig = await env.EMAIL_STORAGE.get(
|
const feedConfig = await FeedRepository.from(env).getConfig(feedId);
|
||||||
`feed:${feedId}:config`,
|
|
||||||
"json",
|
|
||||||
);
|
|
||||||
if (!feedConfig) {
|
if (!feedConfig) {
|
||||||
return c.text("Not Found: feed does not exist", 404);
|
return c.text("Not Found: feed does not exist", 404);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { http, HttpResponse } from "msw";
|
|||||||
import worker from "../index";
|
import worker from "../index";
|
||||||
import { server, createMockEnv, MockR2 } from "../test/setup";
|
import { server, createMockEnv, MockR2 } from "../test/setup";
|
||||||
import type { Env } from "../types";
|
import type { Env } from "../types";
|
||||||
import type { ForwardEmailPayload } from "../lib/forwardemail";
|
import type { ForwardEmailPayload } from "../infrastructure/forwardemail";
|
||||||
|
|
||||||
const AUTHORIZED_IP = "138.197.213.185"; // first fallback IP
|
const AUTHORIZED_IP = "138.197.213.185"; // first fallback IP
|
||||||
const DOMAIN = "test.getmynews.app";
|
const DOMAIN = "test.getmynews.app";
|
||||||
@@ -135,6 +135,15 @@ describe("POST /api/inbound — handler logic", () => {
|
|||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 410 when the feed has expired", async () => {
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({ expires_at: Date.now() - 1000 }),
|
||||||
|
);
|
||||||
|
const res = await worker.fetch(makeRequest(makePayload()), env);
|
||||||
|
expect(res.status).toBe(410);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 200 when sender matches allowlist by exact address", async () => {
|
it("returns 200 when sender matches allowlist by exact address", async () => {
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
`feed:${VALID_FEED_ID}:config`,
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
@@ -256,6 +265,47 @@ describe("POST /api/inbound — attachment upload", () => {
|
|||||||
expect(mockR2._has(attachmentId)).toBe(true);
|
expect(mockR2._has(attachmentId)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists the attachment Content-ID and rewrites inline cid: images on the entry page", async () => {
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
);
|
||||||
|
const payload = makePayload({
|
||||||
|
html: '<p>hi</p><img src="cid:ii_mpi85rqy0" alt="pic"/>',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: "pic.png",
|
||||||
|
contentType: "image/png",
|
||||||
|
cid: "ii_mpi85rqy0",
|
||||||
|
content: { type: "Buffer", data: [137, 80, 78] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
const emailData = (await env.EMAIL_STORAGE.get(metadata.emails[0].key, {
|
||||||
|
type: "json",
|
||||||
|
})) as any;
|
||||||
|
const attachmentId = emailData.attachments[0].id;
|
||||||
|
expect(emailData.attachments[0].contentId).toBe("ii_mpi85rqy0");
|
||||||
|
|
||||||
|
const entryRes = await worker.fetch(
|
||||||
|
new Request(
|
||||||
|
`https://${DOMAIN}/entries/${VALID_FEED_ID}/${metadata.emails[0].receivedAt}`,
|
||||||
|
),
|
||||||
|
env,
|
||||||
|
);
|
||||||
|
expect(entryRes.status).toBe(200);
|
||||||
|
const html = await entryRes.text();
|
||||||
|
expect(html).toContain(`/files/${attachmentId}/pic.png`);
|
||||||
|
expect(html).not.toContain("cid:ii_mpi85rqy0");
|
||||||
|
});
|
||||||
|
|
||||||
it("skips R2 when attachment content is null", async () => {
|
it("skips R2 when attachment content is null", async () => {
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
`feed:${VALID_FEED_ID}:config`,
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
import { ForwardEmailPayload, handleForwardEmail } from "../lib/forwardemail";
|
import {
|
||||||
|
ForwardEmailPayload,
|
||||||
|
handleForwardEmail,
|
||||||
|
} from "../infrastructure/forwardemail";
|
||||||
|
|
||||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+9
-4
@@ -1,8 +1,10 @@
|
|||||||
import { Context } from "hono";
|
import { Context } from "hono";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
import { generateRssFeed } from "../utils/feed-generator";
|
import { generateRssFeed } from "../infrastructure/feed-generator";
|
||||||
import { fetchFeedData } from "../utils/feed-fetcher";
|
import { fetchFeedData } from "../application/feed-fetcher";
|
||||||
import { baseUrl, feedRssUrl } from "../utils/urls";
|
import { baseUrl, feedRssUrl } from "../infrastructure/urls";
|
||||||
|
import { isExpired } from "../domain/feed";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
|
||||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
@@ -11,10 +13,13 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
return new Response("Feed ID is required", { status: 400 });
|
return new Response("Feed ID is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedData = await fetchFeedData(feedId, c.env);
|
const feedData = await fetchFeedData(FeedId.unchecked(feedId), c.env);
|
||||||
if (!feedData) {
|
if (!feedData) {
|
||||||
return new Response("Feed not found", { status: 404 });
|
return new Response("Feed not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
if (isExpired(feedData.feedConfig)) {
|
||||||
|
return new Response("Feed has expired", { status: 410 });
|
||||||
|
}
|
||||||
|
|
||||||
const base = baseUrl(c.env);
|
const base = baseUrl(c.env);
|
||||||
const selfUrl = new URL(c.req.url).origin + `/rss/${feedId}`;
|
const selfUrl = new URL(c.req.url).origin + `/rss/${feedId}`;
|
||||||
|
|||||||
@@ -341,21 +341,6 @@ function refreshFeedRowCache(): void {
|
|||||||
updateFeedSelectionState();
|
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 {
|
function setupFeedDeleteButtons(): void {
|
||||||
const buttons = Array.from(
|
const buttons = Array.from(
|
||||||
document.querySelectorAll<HTMLButtonElement>(
|
document.querySelectorAll<HTMLButtonElement>(
|
||||||
|
|||||||
@@ -321,21 +321,6 @@ function refreshEmailRowCache(): void {
|
|||||||
updateEmailSelectionState();
|
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 {
|
function setupEmailDeleteButtons(): void {
|
||||||
Array.from(
|
Array.from(
|
||||||
document.querySelectorAll<HTMLButtonElement>(
|
document.querySelectorAll<HTMLButtonElement>(
|
||||||
|
|||||||
Vendored
+32
@@ -0,0 +1,32 @@
|
|||||||
|
// Ambient declarations for browser globals injected at runtime via inline
|
||||||
|
// <script> bundles (see src/scripts/toast.ts and src/scripts/httpErrors.ts).
|
||||||
|
// Those helpers attach themselves to window rather than being importable, so the
|
||||||
|
// client TypeScript needs them declared here to type-check.
|
||||||
|
|
||||||
|
interface ToastOptions {
|
||||||
|
type?: "info" | "success" | "warning" | "error" | (string & {});
|
||||||
|
loading?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastHandle {
|
||||||
|
dismiss: () => void;
|
||||||
|
update: (message: string, opts?: ToastOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParseJsonOptions {
|
||||||
|
prefix?: string;
|
||||||
|
allowText?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
showToast: (message: string, opts?: ToastOptions) => ToastHandle;
|
||||||
|
parseJsonResponseOrThrow: (
|
||||||
|
res: Response,
|
||||||
|
opts?: ParseJsonOptions,
|
||||||
|
) => Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -13,6 +13,39 @@
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Create-feed accordion */
|
||||||
|
.create-feed-card > summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-feed-card > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-feed-summary h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-feed-card > summary::after {
|
||||||
|
content: "+";
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-feed-card[open] > summary::after {
|
||||||
|
content: "\2212";
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-feed-card[open] > summary {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
/* Feed header styling */
|
/* Feed header styling */
|
||||||
.feed-header {
|
.feed-header {
|
||||||
margin-bottom: var(--spacing-md);
|
margin-bottom: var(--spacing-md);
|
||||||
@@ -24,6 +57,26 @@
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Per-feed favicon (list + table views) */
|
||||||
|
.feed-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: middle;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-title .feed-icon {
|
||||||
|
margin-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-title-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.feed-description {
|
.feed-description {
|
||||||
font-size: var(--font-size-md);
|
font-size: var(--font-size-md);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
@@ -749,6 +802,63 @@ table.table code {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-cell .truncate {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-indicator {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
margin-top: var(--spacing-lg);
|
||||||
|
padding-top: var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments h2 {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
margin: 0 0 var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-size {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
/* Compact copy-to-clipboard for table cells */
|
/* Compact copy-to-clipboard for table cells */
|
||||||
.copyable.copyable-inline {
|
.copyable.copyable-inline {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -972,3 +1082,167 @@ table.table code {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Feed TTL — expiry badges */
|
||||||
|
.pill-expiry {
|
||||||
|
background-color: rgba(255, 159, 10, 0.15);
|
||||||
|
color: var(--color-warning);
|
||||||
|
border-color: rgba(255, 159, 10, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-expired {
|
||||||
|
background-color: rgba(255, 69, 58, 0.15);
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-color: rgba(255, 69, 58, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feed TTL — expired feed row */
|
||||||
|
.feed-expired .feed-header,
|
||||||
|
.feed-expired td:not(:last-child) {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feed TTL — disabled-looking action buttons on expired feeds */
|
||||||
|
.button-disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feed TTL — card states in edit form */
|
||||||
|
.card-warning {
|
||||||
|
border-color: rgba(255, 159, 10, 0.4);
|
||||||
|
background-color: rgba(255, 159, 10, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status page — stat cards grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value-time {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value-success {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value-danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status page — hero featured metric */
|
||||||
|
.stat-hero {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-hero-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-hero-value {
|
||||||
|
font-size: 3.25rem;
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-hero-label {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-hero-aside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-hero-aside-value {
|
||||||
|
font-size: var(--font-size-xxl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-hero-aside-label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status page — thematic sections */
|
||||||
|
.stat-section {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-section-title {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin: 0 0 var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.stat-hero-aside {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value-time {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user