mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d778849e02 | |||
| 5083f7e151 | |||
| ffe96586c7 | |||
| 3242f0e3f1 | |||
| 1332362005 | |||
| cbf6bb7e7e | |||
| 0f18d4c123 | |||
| e8078b2673 | |||
| ea7332b752 | |||
| 8cd8c940fa | |||
| fe5728de59 | |||
| 35262d5d0b | |||
| a18d9f165f | |||
| 8a0dbf25b0 | |||
| 6236274ce8 | |||
| 5362d478e3 | |||
| ee0e7eef5d | |||
| dc2ccfdd1c | |||
| 86d18eb390 | |||
| 3c48181c05 | |||
| df5546fedd | |||
| 021aeabd05 | |||
| 69ed07db51 | |||
| 6dad6741ed | |||
| d2f3e1ca27 | |||
| 664d0c02ba | |||
| 757dd3a53f | |||
| 16029460bc | |||
| 82a4bd8341 | |||
| e86beeeb8a | |||
| 7086526670 | |||
| 70552e5fa6 | |||
| 4e3d378850 | |||
| 421430632e | |||
| 5f05068449 | |||
| fc86b5f7d1 | |||
| 0929d4f0b7 | |||
| 4d47ca623b | |||
| 7019800769 | |||
| f44c6c1eda | |||
| 1525b36cab | |||
| c4d591b962 | |||
| 36d58ade48 | |||
| 79bb4902b9 | |||
| d561b6b81f | |||
| e4e3d62f5a | |||
| f2e0353438 | |||
| 6bf11493ab | |||
| 5b54659b4d | |||
| 0488b9d066 | |||
| 2a3aeb8a18 | |||
| b3a979fd03 | |||
| 1a4a479190 | |||
| f7f10779bc | |||
| 0abd5f306c | |||
| 334713fbd9 | |||
| 97ce9a62b4 | |||
| 81e46c9026 | |||
| 1583e95875 | |||
| 2c450817df | |||
| 6cb036fe2c |
@@ -21,6 +21,42 @@ jobs:
|
|||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|
||||||
|
# The tagged commit is the release: `npm run release` commits the bare
|
||||||
|
# X.Y.Z to it (main otherwise carries a `-develop` suffix). Verify the tag
|
||||||
|
# matches that committed version exactly — this catches tagging the wrong
|
||||||
|
# commit (e.g. a `-develop` one) without rewriting anything.
|
||||||
|
- name: Verify package.json matches the tag
|
||||||
|
env:
|
||||||
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
VERSION="${TAG_NAME#v}"
|
||||||
|
PKG="$(node -p 'require("./package.json").version')"
|
||||||
|
if [ "$VERSION" != "$PKG" ]; then
|
||||||
|
echo "Tag $TAG_NAME does not match package.json ($PKG)." >&2
|
||||||
|
echo "The tagged commit must carry the bare release version ($VERSION)." >&2
|
||||||
|
echo "Cut releases with: npm run release $VERSION" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Release notes come from the CHANGELOG section for this version, which is
|
||||||
|
# written incrementally and reviewed in PRs — never hand-typed at release.
|
||||||
|
- name: Extract release notes from CHANGELOG
|
||||||
|
env:
|
||||||
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
VERSION="${TAG_NAME#v}"
|
||||||
|
awk -v ver="$VERSION" '
|
||||||
|
$0 ~ "^## \\[" ver "\\]" {grab=1; next}
|
||||||
|
/^## \[/ && grab {exit}
|
||||||
|
grab { if (!started && $0 ~ /^[[:space:]]*$/) next; started=1; print }
|
||||||
|
' CHANGELOG.md > release-notes.md
|
||||||
|
if [ ! -s release-notes.md ]; then
|
||||||
|
echo "No CHANGELOG section found for $VERSION." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Release notes for $VERSION:"
|
||||||
|
cat release-notes.md
|
||||||
|
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
|
|
||||||
- name: Locate bundled output
|
- name: Locate bundled output
|
||||||
@@ -41,5 +77,5 @@ jobs:
|
|||||||
TAG_NAME: ${{ github.ref_name }}
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
BUNDLE_PATH: ${{ steps.bundle.outputs.path }}
|
BUNDLE_PATH: ${{ steps.bundle.outputs.path }}
|
||||||
run: |
|
run: |
|
||||||
gh release create "$TAG_NAME" --generate-notes --verify-tag || true
|
gh release create "$TAG_NAME" --notes-file release-notes.md --verify-tag || true
|
||||||
gh release upload "$TAG_NAME" "$BUNDLE_PATH" --clobber
|
gh release upload "$TAG_NAME" "$BUNDLE_PATH" --clobber
|
||||||
|
|||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project are documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
Keep the `## [Unreleased]` section up to date **as part of every change** (the
|
||||||
|
same rule as the rest of the docs). At release time `npm run release X.Y.Z`
|
||||||
|
promotes this section to `## [X.Y.Z]` and the Release workflow publishes it
|
||||||
|
verbatim as the GitHub Release notes — so what you write here is what ships.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.1] - 2026-05-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Feed self link (RSS/Atom/JSON) is derived from the configured domain instead
|
||||||
|
of the request host — it no longer leaks the `workers.dev` host when a feed is
|
||||||
|
reached directly, and now matches the alternate link.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-05-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Native feed detection** — incoming newsletters are inspected for a
|
||||||
|
self-advertised Atom/RSS/JSON feed (`rel=alternate` links in the email HTML);
|
||||||
|
discovered feeds are stored per sender on the Feed aggregate and surfaced as
|
||||||
|
chips on the feed detail page, a dashboard pill, and (read-only) on the REST
|
||||||
|
`Feed` schema, with a dismissable notice.
|
||||||
|
- **Subscription confirmation surfacing** — confirmation emails ("click to
|
||||||
|
confirm your subscription") are detected at ingestion and flagged on the feed;
|
||||||
|
the admin UI surfaces the confirmation link, a badge, a dashboard pill, and an
|
||||||
|
inline banner (all dismissable), tightened against false positives via a
|
||||||
|
weak-signal heuristic.
|
||||||
|
- **JSON Feed 1.1** output (`/json/:feedId`).
|
||||||
|
- **OPML export** of all feeds (`/admin/opml`).
|
||||||
|
- **Conditional GET** (ETag / Last-Modified / 304) on the feed routes.
|
||||||
|
- Per-feed **Subscribe chips** for RSS/Atom/JSON with copy / open / validate
|
||||||
|
actions, reused across dashboard and feed detail page.
|
||||||
|
- Email detail page links to its public entry page; land on the feed's emails
|
||||||
|
page right after creation.
|
||||||
|
- Optional **per-feed "sender in title"** toggle.
|
||||||
|
- Running **version** shown in the admin/status footer, `/health`, and
|
||||||
|
`/api/v1/stats`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Read/write identity decoupling (privacy)** — the public read id (`FeedId`,
|
||||||
|
used in `/rss/:feedId`) is fully decoupled from the inbound email address
|
||||||
|
(`MailboxId`, `noun.noun.NN`); a feed's read URL never reveals its inbound
|
||||||
|
alias and vice-versa (reading `/rss/<noun.noun.NN>` 404s).
|
||||||
|
- Sender display name, site URL and parsing now owned by the `EmailAddress`
|
||||||
|
value object (DDD cleanup).
|
||||||
|
- Release version is derived from the git tag; CI guards against tagging the
|
||||||
|
wrong commit.
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-05-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Optional `FALLBACK_FORWARD_ADDRESS`: forward non-feed mail to a verified
|
||||||
|
address so a domain catch-all can point at kill-the-news without swallowing
|
||||||
|
personal mail (forwarded mail is counted in the stats dashboard).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Feed, entry, and attachment responses send `X-Robots-Tag: noindex`; a new
|
||||||
|
`/robots.txt` disallows `/rss`, `/atom`, `/entries`, `/files`, and `/admin` —
|
||||||
|
private feeds and emails stay out of search engines.
|
||||||
|
- Relative links/images in email bodies are absolutized against the sender's
|
||||||
|
site; lazy-loaded images are promoted so they don't render blank.
|
||||||
|
- Feed `<title>` is plain text (HTML stripped, entities decoded).
|
||||||
|
- Sender-site derivation moved onto the `EmailAddress` value object
|
||||||
|
(`siteBaseUrl`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- XML-illegal control characters are stripped from generated feeds (valid astral
|
||||||
|
characters such as emoji preserved).
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-05-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Versioned REST API (`/api/v1/feeds*`) with an OpenAPI 3.1 spec
|
||||||
|
(`/api/openapi.json`) and rendered reference docs via Scalar (`/api/docs`).
|
||||||
|
- `/api/v1/stats` as the canonical public stats endpoint (JSON + CORS).
|
||||||
|
- Optional R2 attachment storage with a config toggle, storage metrics, download
|
||||||
|
links on the email/admin views, and inline `cid:` image rendering.
|
||||||
|
- Project favicon (`/favicon.svg`, `/favicon.ico`) and per-feed favicon derived
|
||||||
|
from the last sender's domain (`/favicon/:feedId`).
|
||||||
|
- RFC 8058 one-click unsubscribe dispatched when a feed is deleted.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Large internal refactor toward a clean domain-driven architecture; redesigned
|
||||||
|
landing/status page.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- The deprecated `/api/stats` endpoint (use `/api/v1/stats`).
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-05-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Atom feed format** (`/atom/:feedId`) alongside RSS 2.0.
|
||||||
|
- **WebSub push notifications** advertised via `Link` header for real-time
|
||||||
|
delivery instead of polling.
|
||||||
|
- **HTML email processing** — bodies sanitized via `linkedom` + `escape-html`
|
||||||
|
(XSS prevention, MSO style stripping, plain-text fallback).
|
||||||
|
- **Email attachments as RSS enclosures**, stored in R2 and served at
|
||||||
|
`/files/:attachmentId/:filename`.
|
||||||
|
- **Sender blocklist** with 4-level priority matching and a quick-add dropdown.
|
||||||
|
- **`EMAIL_DOMAIN`** env var to separate web domain and email domain.
|
||||||
|
- **Authelia / reverse-proxy auth** via trusted headers (`Remote-User`,
|
||||||
|
`X-Forwarded-User`).
|
||||||
|
- Demo environment auto-deployed to `demo.kill-the.news` with a nightly KV
|
||||||
|
reset.
|
||||||
|
- Admin UI redesign (Inter font, orange theme), client scripts compiled via
|
||||||
|
esbuild, templates on `hono/jsx`.
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/juherr/kill-the-news/compare/v0.3.1...HEAD
|
||||||
|
[0.3.1]: https://github.com/juherr/kill-the-news/compare/v0.3.0...v0.3.1
|
||||||
|
[0.3.0]: https://github.com/juherr/kill-the-news/compare/v0.2.1...v0.3.0
|
||||||
|
[0.2.1]: https://github.com/juherr/kill-the-news/compare/v0.2.0...v0.2.1
|
||||||
|
[0.2.0]: https://github.com/juherr/kill-the-news/compare/v0.1.0...v0.2.0
|
||||||
|
[0.1.0]: https://github.com/juherr/kill-the-news/releases/tag/v0.1.0
|
||||||
@@ -26,6 +26,14 @@ npx vitest run src/routes/admin.test.ts
|
|||||||
|
|
||||||
kill-the-news is a Cloudflare Worker that ingests email newsletters and exposes them as private RSS/Atom feeds. Self-hosted, free-tier-friendly (Cloudflare + ForwardEmail).
|
kill-the-news is a Cloudflare Worker that ingests email newsletters and exposes them as private RSS/Atom feeds. Self-hosted, free-tier-friendly (Cloudflare + ForwardEmail).
|
||||||
|
|
||||||
|
## Development approach
|
||||||
|
|
||||||
|
Work **test-first (TDD)** and **domain-driven (DDD)** in this repo — both are first-class, not optional.
|
||||||
|
|
||||||
|
**TDD.** Write or extend a test before/with the change, then make it pass. Mirror the existing test layout (`*.test.ts` next to the source, `createMockEnv()` from `src/test/setup.ts`, MSW for outbound HTTP). End every change green: `npx tsc --noEmit`, `npm test`, and `npm run build` (dry-run deploy) must all pass before declaring done.
|
||||||
|
|
||||||
|
**DDD.** Before adding logic, check whether the domain already models the concept — reach for the value objects in `src/domain/value-objects/` (`EmailAddress`, `Domain`, `FeedId`, `MailboxId`, `Lifetime`, `SenderPolicy`) and the `Feed` aggregate rather than re-deriving things ad hoc. New behavior belongs on the type that owns the data (e.g. "sender site URL" lives on `EmailAddress`, not in a helper). Respect the layering and aggregate rules below — imports point inward (routes → application → domain; infrastructure implements ports), and never reach across a layer for convenience (e.g. importing a favicon/infra helper just to parse a domain). When the same derivation appears twice, that's the signal to push it onto a domain type.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Single Cloudflare Worker built with Hono. Routes:
|
Single Cloudflare Worker built with Hono. Routes:
|
||||||
@@ -38,11 +46,13 @@ Single Cloudflare Worker built with Hono. Routes:
|
|||||||
| `GET /api/v1/stats` | Public monitoring counters (JSON, CORS); canonical stats endpoint |
|
| `GET /api/v1/stats` | Public monitoring counters (JSON, CORS); canonical stats endpoint |
|
||||||
| `GET /api/openapi.json` | OpenAPI 3.1 spec (public) |
|
| `GET /api/openapi.json` | OpenAPI 3.1 spec (public) |
|
||||||
| `GET /api/docs` | Rendered API reference (Scalar, 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 (conditional GET: ETag/Last-Modified/304) |
|
||||||
| `GET /atom/:feedId` | Public Atom feed (with WebSub hub header) |
|
| `GET /atom/:feedId` | Public Atom feed (WebSub hub header; conditional GET ETag/304) |
|
||||||
|
| `GET /json/:feedId` | Public JSON Feed |
|
||||||
| `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 |
|
||||||
|
| `GET /admin/opml` | OPML export of all feeds (admin-protected) |
|
||||||
| `/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.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 /favicon/:feedId` | Per-feed favicon from the last sender's domain (falls back to project) |
|
||||||
@@ -65,7 +75,8 @@ src/
|
|||||||
events.ts # FeedEvent union (FeedCreated, EmailIngested) — each carries its feedId
|
events.ts # FeedEvent union (FeedCreated, EmailIngested) — each carries its feedId
|
||||||
email-parser.ts # Email parsing (addresses, headers, encoded words)
|
email-parser.ts # Email parsing (addresses, headers, encoded words)
|
||||||
format.ts # Pure formatting helpers (formatBytes)
|
format.ts # Pure formatting helpers (formatBytes)
|
||||||
value-objects/ # FeedId, EmailAddress, Domain, SenderPolicy, Lifetime (immutable, self-validating)
|
native-feed.ts # Detect a newsletter's self-advertised Atom/RSS/JSON feed (pure)
|
||||||
|
value-objects/ # FeedId (opaque read id), MailboxId (inbound noun.noun.NN), EmailAddress, Domain, SenderPolicy, Lifetime (immutable, self-validating)
|
||||||
application/ # Use-cases / orchestration (wires domain + infrastructure)
|
application/ # Use-cases / orchestration (wires domain + infrastructure)
|
||||||
feed-service.ts # createFeedRecord / editFeedDetails / editFeed / deleteFeedRecord (admin UI + REST API)
|
feed-service.ts # createFeedRecord / editFeedDetails / editFeed / deleteFeedRecord (admin UI + REST API)
|
||||||
feed-cleanup.ts # Feed/email storage cleanup: purgeFeedKeysStep, collectUnsubscribeUrls, attachment+key deletion
|
feed-cleanup.ts # Feed/email storage cleanup: purgeFeedKeysStep, collectUnsubscribeUrls, attachment+key deletion
|
||||||
@@ -86,7 +97,8 @@ src/
|
|||||||
worker.ts # Typed worker / waitUntil helper
|
worker.ts # Typed worker / waitUntil helper
|
||||||
attachments.ts # R2 bucket accessor
|
attachments.ts # R2 bucket accessor
|
||||||
favicon-fetcher.ts # Outbound favicon fetch + cache (uses IconRepository)
|
favicon-fetcher.ts # Outbound favicon fetch + cache (uses IconRepository)
|
||||||
feed-generator.ts # RSS/Atom XML generation
|
feed-generator.ts # RSS/Atom/JSON Feed XML+JSON generation
|
||||||
|
http-cache.ts # Conditional-GET validators (ETag/Last-Modified) for feed routes
|
||||||
html-processor.ts # Email HTML sanitization / inline cid: rewriting
|
html-processor.ts # Email HTML sanitization / inline cid: rewriting
|
||||||
websub.ts # WebSub subscription management + delivery
|
websub.ts # WebSub subscription management + delivery
|
||||||
unsubscribe.ts # RFC 8058 one-click unsubscribe dispatch
|
unsubscribe.ts # RFC 8058 one-click unsubscribe dispatch
|
||||||
@@ -95,6 +107,8 @@ src/
|
|||||||
inbound.ts # ForwardEmail webhook handler
|
inbound.ts # ForwardEmail webhook handler
|
||||||
rss.ts # RSS feed renderer
|
rss.ts # RSS feed renderer
|
||||||
atom.ts # Atom feed renderer
|
atom.ts # Atom feed renderer
|
||||||
|
json.ts # JSON Feed renderer
|
||||||
|
opml.ts # OPML export of all feeds (admin-protected handler)
|
||||||
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
|
||||||
@@ -127,16 +141,19 @@ 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?, expires_at? }> }` |
|
| `feeds:list` | `{ feeds: Array<{ id, title, description?, mailbox_id?, expires_at? }> }` |
|
||||||
| `feed:<feedId>:config` | `FeedConfig` |
|
| `feed:<feedId>:config` | `FeedConfig` |
|
||||||
| `feed:<feedId>:metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds?, inlineAttachmentIds? }> }` |
|
| `feed:<feedId>:metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds?, inlineAttachmentIds? }>, nativeFeeds?: Record<string, NativeFeed[]>, nativeFeedDismissed?: boolean }` |
|
||||||
| `feed:<feedId>:<timestamp>` | Full `EmailData` |
|
| `feed:<feedId>:<timestamp>` | Full `EmailData` |
|
||||||
|
| `inbound:<mailboxId>` | The feed id this inbound address (`noun.noun.NN`) routes to (resolved only at reception) |
|
||||||
| `websub:subs:<feedId>` | `WebSubSubscription[]` (per-feed subscriber list) |
|
| `websub:subs:<feedId>` | `WebSubSubscription[]` (per-feed subscriber list) |
|
||||||
| `icon:<domain>` | Cached favicon record (base64 + content type; negative entries allowed) |
|
| `icon:<domain>` | Cached favicon record (base64 + content type; negative entries allowed) |
|
||||||
| `stats:counters` | `Counters` (cumulative monitoring counters singleton) |
|
| `stats:counters` | `Counters` (cumulative monitoring counters singleton) |
|
||||||
|
|
||||||
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.
|
`feedId` is an **opaque random token** — the feed's identity, its KV storage key, and the public read id (`/rss/:feedId`). It is **decoupled** from the inbound email address: each feed also has a friendly `MailboxId` (`noun.noun.NN`) whose only mapping to the feed is the `inbound:<mailboxId>` secondary index, read **only** at email reception. So the feed's read URL never reveals its inbound address and vice-versa; reading `/rss/<noun.noun.NN>` 404s.
|
||||||
|
|
||||||
|
The KV key schema lives in `src/domain/feed-keys.ts` (pure, framework-agnostic) — never inline a `feed:`/`feeds:list`/`inbound:`/`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
|
### Domain & layering rules
|
||||||
|
|
||||||
@@ -145,11 +162,12 @@ The KV key schema lives in `src/domain/feed-keys.ts` (pure, framework-agnostic)
|
|||||||
- **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 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.
|
- **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.
|
- **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.
|
- **`feeds:list` and the `inbound:` index stay in sync automatically.** `FeedRepository.save`/`saveConfig` upsert the registry entry via `toListItemDTO(feed.id, feed.state())` _and_ write the `inbound:<mailbox> → feedId` index — services never mirror title/description/expiry into the list by hand. Symmetrically, `removeFromList`/`removeFromListBulk` drop the inbound index (the mailbox is cached on the list item) — so the index lives outside the `feed:<id>:` prefix the key purge sweeps, but is still cleared wherever a feed leaves the list (`deleteFeedRecord`, bulk admin delete, the cron). `deleteFeedFastDetailed` only removes config+metadata; it does **not** touch the index.
|
||||||
- Read-only RSS/Atom rendering uses the `feed-fetcher` read model, not the aggregate (no invariant to enforce on the hot path).
|
- 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`).
|
- 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.
|
- **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.
|
- **`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.generate()` (opaque) for a new feed, `FeedId.unchecked(param)` at the read/HTTP edge (no revalidation: a bad id just misses in KV and 404s) — 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.
|
||||||
|
- **`FeedId` (read) vs `MailboxId` (write) are distinct identities.** `MailboxId` (`noun.noun.NN`) owns the inbound address and the untrusted-input boundary: `MailboxId.parse(address)` at reception is the only place an external string becomes a mailbox. Ingestion then resolves it to the feed via `FeedRepository.resolveInbound(mailbox)` and loads by the resulting `FeedId`. The mailbox is an attribute of the feed (held on `FeedState.mailboxId`, exposed as `Feed.mailboxId: MailboxId`, persisted as `mailbox_id`, projected into `feeds:list`); the `mailbox@domain` shape lives on the VO (`MailboxId.emailAddress(domain)`), with `urls.feedEmailAddress` only resolving the env domain. Never derive one id from the other — the decoupling is the privacy guarantee.
|
||||||
|
|
||||||
### Worker bindings (`Env`)
|
### Worker bindings (`Env`)
|
||||||
|
|
||||||
@@ -183,11 +201,31 @@ MSW (`msw/node`) handles external HTTP mocks. Tests that hit validation paths in
|
|||||||
- `ADMIN_PASSWORD` is set via `wrangler secret put` — never in config files
|
- `ADMIN_PASSWORD` is set via `wrangler secret put` — never in config files
|
||||||
- Keep `compatibility_date` current on runtime upgrades
|
- Keep `compatibility_date` current on runtime upgrades
|
||||||
|
|
||||||
|
## Releasing (read before cutting a release)
|
||||||
|
|
||||||
|
`package.json` `version` is inlined at build time as `APP_VERSION` (`src/config/version.ts`) and surfaced in the admin/status footer, `/health`, and `/api/v1/stats`. **`main` always carries a `-develop` pre-release suffix** (e.g. `0.4.0-develop`) so a dev build is never mistaken for a shipped one.
|
||||||
|
|
||||||
|
When asked to "release X.Y.Z", **run the script — never tag/bump/write notes by hand**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release X.Y.Z # next dev cycle defaults to next minor
|
||||||
|
npm run release X.Y.Z A.B.C # ...or pass an explicit next dev base (e.g. a patch line)
|
||||||
|
```
|
||||||
|
|
||||||
|
`X.Y.Z` must equal `main`'s current `X.Y.Z-develop` base. `scripts/release.sh` guards (clean tree, on `main`, synced with origin, version match, **non-empty `## [Unreleased]`**), then atomically: promotes `CHANGELOG.md`'s `## [Unreleased]` → `## [X.Y.Z]`, commits the **bare** `X.Y.Z` as a real release commit, tags it, opens the next `-develop` cycle (fresh `## [Unreleased]` + bump), and pushes `main` + the tag after a confirmation prompt.
|
||||||
|
|
||||||
|
The `v*` tag triggers the Release workflow (`.github/workflows/release.yml`), which **verifies** the tagged commit's `package.json` equals the tag exactly (wrong/`-develop`-commit guard), builds, and publishes a GitHub Release whose notes are the `## [X.Y.Z]` CHANGELOG section. **Release notes are never hand-typed** — they come from `CHANGELOG.md`, which you keep current under `## [Unreleased]` as part of every change (treat it like the other docs). Full flow in [CONTRIBUTING.md](CONTRIBUTING.md) under "Releasing".
|
||||||
|
|
||||||
## When changing behavior
|
## When changing behavior
|
||||||
|
|
||||||
Update together:
|
**Always document evolutions** — treat docs as part of the change, not a follow-up. When you add or change a feature, update the relevant docs in the same change:
|
||||||
|
|
||||||
|
- `CHANGELOG.md` — add a bullet under `## [Unreleased]` for any user-facing change (this is what the next release notes are built from; never deferred to release time)
|
||||||
- `README.md`
|
- `README.md`
|
||||||
- `INSTALL.md` (setup, deployment, and configuration guide)
|
- `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`
|
||||||
|
|
||||||
|
Keep it proportionate: user-facing or config changes warrant doc updates; purely internal refactors usually don't.
|
||||||
|
|
||||||
|
**Marketing landing page (`docs/index.html`).** This is the public GH Pages site (served at the `CNAME` domain), not the in-app status page (`src/routes/home.tsx`). When a feature is also a selling point — something a prospective self-hoster would care about (privacy guarantees, full-body capture, burnable aliases, reader compatibility, automation/API, AI features…) — surface it there too (hero copy or a feature card), matching the existing section/card style. Internal correctness fixes don't belong on the landing page; differentiators do.
|
||||||
|
|||||||
@@ -72,6 +72,41 @@ docs(readme): add Continuous deployment section
|
|||||||
|
|
||||||
Common types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`.
|
Common types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`.
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
The running version is read from `package.json` `version` and inlined at build
|
||||||
|
time (footer, `/health`, `/api/v1/stats`). `main` **always** carries a
|
||||||
|
`-develop` pre-release suffix (e.g. `0.4.0-develop`) so a dev build is never
|
||||||
|
mistaken for a shipped one — `0.4.0-develop` sorts _below_ `0.4.0` per SemVer,
|
||||||
|
meaning "heading toward 0.4.0, not yet released".
|
||||||
|
|
||||||
|
**Cut releases with one command — never by hand:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release X.Y.Z # next dev cycle defaults to the next minor
|
||||||
|
npm run release X.Y.Z A.B.C # ...or pass an explicit next dev base (e.g. a patch line)
|
||||||
|
```
|
||||||
|
|
||||||
|
`X.Y.Z` must equal `main`'s current `X.Y.Z-develop` base. The script
|
||||||
|
(`scripts/release.sh`) guards (clean tree, on `main`, in sync with `origin`,
|
||||||
|
version match, non-empty changelog), then in one shot:
|
||||||
|
|
||||||
|
1. promotes the `## [Unreleased]` section of `CHANGELOG.md` to `## [X.Y.Z]`,
|
||||||
|
2. commits the **bare** `X.Y.Z` to `main` (a real release commit) and tags it,
|
||||||
|
3. opens the next `-develop` cycle (a fresh `## [Unreleased]` + bumped version),
|
||||||
|
4. pushes `main` + the tag (after showing you the notes and asking to confirm).
|
||||||
|
|
||||||
|
The `v*` tag triggers the Release workflow (`.github/workflows/release.yml`),
|
||||||
|
which **verifies** the tagged commit's `package.json` equals the tag exactly
|
||||||
|
(catching a wrong or `-develop` commit), builds the bundle, and publishes a
|
||||||
|
GitHub Release whose notes are the `## [X.Y.Z]` section of `CHANGELOG.md` — so the
|
||||||
|
changelog you maintained in-repo is what ships. Keep `## [Unreleased]` up to date
|
||||||
|
**as part of every change**; the release notes are never hand-typed.
|
||||||
|
|
||||||
|
If you ever release manually, the tagged commit must carry the bare `X.Y.Z` in
|
||||||
|
`package.json` and the matching `## [X.Y.Z]` section must exist in
|
||||||
|
`CHANGELOG.md` — the workflow fails fast otherwise.
|
||||||
|
|
||||||
## Reporting bugs and requesting features
|
## Reporting bugs and requesting features
|
||||||
|
|
||||||
Open an issue at
|
Open an issue at
|
||||||
|
|||||||
+52
@@ -114,6 +114,58 @@ Scope the token to the relevant **account** and, for custom domains, the relevan
|
|||||||
- Keep `compatibility_date` fresh when doing runtime upgrades.
|
- Keep `compatibility_date` fresh when doing runtime upgrades.
|
||||||
- `ADMIN_PASSWORD` is a Cloudflare Worker secret, not a plain env var in config.
|
- `ADMIN_PASSWORD` is a Cloudflare Worker secret, not a plain env var in config.
|
||||||
|
|
||||||
|
### Native feed detection
|
||||||
|
|
||||||
|
When an incoming email's HTML advertises the newsletter's own syndication feed via `<link rel="alternate" type="application/atom+xml|rss+xml|feed+json">`, the worker captures those URLs at ingestion and shows them per feed — no configuration required:
|
||||||
|
|
||||||
|
- **Email detail page** — a "Native feeds" chip group lists each discovered feed URL with a copy button.
|
||||||
|
- **Feed dashboard** — a "Native feed available" pill signals that the source publishes its own feed.
|
||||||
|
- **Emails page banner** — a dismissable banner prompts you to subscribe to the source directly; once dismissed it stays hidden.
|
||||||
|
- **REST API** — the read-only `nativeFeeds` array on `GET/POST/PATCH /api/v1/feeds` exposes the same data for automation.
|
||||||
|
|
||||||
|
### Subscription confirmation
|
||||||
|
|
||||||
|
When a newsletter sends a "confirm your email" message, the worker detects it at ingestion using multilingual keyword matching and link scoring. Detected emails are automatically flagged and surfaced throughout the admin UI:
|
||||||
|
|
||||||
|
- **Email detail page** — a dedicated "Confirm your subscription" section appears at the top with a primary button linking directly to the confirmation URL.
|
||||||
|
- **Email list** — a "Confirmation" badge appears next to the subject so pending confirmations stand out at a glance.
|
||||||
|
- **Feed dashboard** — a "Confirmation pending" pill on the feed card signals that action is needed.
|
||||||
|
- **Emails page banner** — a dismissible banner with a "Mark as confirmed" button lets you clear the flag once you've clicked the link.
|
||||||
|
|
||||||
|
**v1 performs no outbound request.** The admin clicks the confirmation link themselves in their browser; the worker only detects and surfaces it. Server-side on-detect actions (auto-click from the worker, or forwarding the original email to a fallback address) are planned for a future version.
|
||||||
|
|
||||||
|
### Catch-all fallback forwarding
|
||||||
|
|
||||||
|
By default, inbound mail that doesn't match a feed is dropped (logged, then discarded). If you want to point a domain's **catch-all** at this worker without losing your personal mail, set an optional fallback address — non-feed mail is forwarded there instead of dropped:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[vars]
|
||||||
|
FALLBACK_FORWARD_ADDRESS = "you@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prerequisite:** the address must be a **verified destination** in _Email → Email Routing → Destination addresses_ (Cloudflare won't forward to an unverified address — `message.forward()` fails, and the worker just logs a warning). This only applies to the Cloudflare Email Workers path (Option A).
|
||||||
|
|
||||||
|
What gets forwarded vs dropped:
|
||||||
|
|
||||||
|
| Situation | Action |
|
||||||
|
| -------------------------------------------------- | --------------------- |
|
||||||
|
| Address isn't a feed (e.g. `you@`, typo) | forward |
|
||||||
|
| Well-formed feed address but no such feed | forward |
|
||||||
|
| Feed exists but is **expired** | drop |
|
||||||
|
| Feed exists but the sender is **blocked/filtered** | drop |
|
||||||
|
| Delivered to a live feed | ingested (no forward) |
|
||||||
|
|
||||||
|
Expired feeds and blocked senders are dropped on purpose, so a real newsletter never leaks into your fallback inbox. Leave the variable unset to keep the original drop-and-log behavior.
|
||||||
|
|
||||||
|
### Inbound address vs feed URL
|
||||||
|
|
||||||
|
Each feed has **two independent identifiers**, on purpose:
|
||||||
|
|
||||||
|
- a friendly **inbound address** you subscribe newsletters with — `noun.noun.NN@yourdomain.com` (e.g. `apple.mountain.42@yourdomain.com`);
|
||||||
|
- an **opaque feed URL** for your reader — `https://yourdomain.com/rss/<random-id>` (also `/atom/<id>`, `/json/<id>`).
|
||||||
|
|
||||||
|
They are not derivable from each other. This means you can hand someone a feed URL without revealing the address that feeds it, and an address harvested by a newsletter sender can't be turned into your feed (requesting `/rss/<your-address>` returns 404). The admin dashboard shows both per feed; copy the address into signup forms and the feed URL into your reader. (Internally the inbound address is mapped to the feed by an `inbound:<address>` KV entry, resolved only when mail arrives.)
|
||||||
|
|
||||||
### Feed size limit
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -16,14 +16,24 @@ kill-the-news keeps the same workflow while avoiding shared domains and shared d
|
|||||||
- Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow)
|
- Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow)
|
||||||
- Inline double-confirm delete interactions with toast feedback in the admin dashboard
|
- Inline double-confirm delete interactions with toast feedback in the admin dashboard
|
||||||
- Resizable + sortable table columns in the admin dashboard (Table view)
|
- Resizable + sortable table columns in the admin dashboard (Table view)
|
||||||
|
- Per-feed "Subscribe" chips in the admin dashboard — copy, open, or validate the feed in one click for each of RSS, Atom, and JSON Feed (validation via the W3C Feed Validator and validator.jsonfeed.org)
|
||||||
- Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`)
|
- Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`)
|
||||||
|
- **Separate inbound address and feed URL** — the address you subscribe with (`apple.mountain.42@yourdomain.com`) and the public feed URL (`/rss/<opaque-id>`) use **independent** ids, so you can share a feed without leaking the address that feeds it, and an address harvested by a newsletter can't be used to read your feed (`/rss/<your-address>` 404s)
|
||||||
- Cloudflare Email Workers ingestion (no third-party service)
|
- Cloudflare Email Workers ingestion (no third-party service)
|
||||||
- ForwardEmail webhook ingestion with source-IP verification (optional alternative)
|
- ForwardEmail webhook ingestion with source-IP verification (optional alternative)
|
||||||
- Optional per-feed sender allowlist (`email@domain.com` or `domain.com`)
|
- Optional per-feed sender allowlist (`email@domain.com` or `domain.com`)
|
||||||
|
- Optional per-feed "sender in title" toggle — renders each entry as `[Sender] Subject` for at-a-glance scanning in your reader
|
||||||
- RSS generation on demand (`/rss/:feedId`)
|
- RSS generation on demand (`/rss/:feedId`)
|
||||||
- Atom feed at `/atom/:feedId`
|
- Atom feed at `/atom/:feedId`
|
||||||
|
- JSON Feed at `/json/:feedId` (natively consumed by NetNewsWire, Reeder, NewsBlur, Feedly)
|
||||||
|
- Bandwidth-friendly polling: RSS/Atom send a strong `ETag` + `Last-Modified` and answer `304 Not Modified` on conditional requests
|
||||||
|
- Duplicate-send dedup: a newsletter delivered twice (matched by `Message-ID`, then by a content hash) is stored once
|
||||||
|
- OPML export of all feeds at `/admin/opml` (admin-protected) for one-click bulk import into any reader
|
||||||
|
- Reader-friendly output: relative links/images absolutized to the sender's site, lazy-loaded images promoted (`data-src` → `src`), plain-text feed titles, and XML-illegal control characters stripped so feeds parse in strict readers
|
||||||
- Per-feed favicon derived from the last sender's domain (`/favicon/:feedId`), cached and shown in feeds + admin
|
- 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
|
- Automatic RFC 8058 one-click unsubscribe when a feed is deleted — stops newsletters from mailing the now-dead address
|
||||||
|
- **Subscription confirmation surfacing** — at ingestion the worker detects "confirm your subscription" emails (multilingual keyword + link scoring) and surfaces them in the admin: a dedicated section with a primary "Confirm subscription" button on the email detail page, a "Confirmation" badge in the email list, a "Confirmation pending" pill on the dashboard, and a banner on the feed's emails page with a "Mark as confirmed" dismiss button; v1 surfaces the link only — no outbound request is made
|
||||||
|
- **Native feed detection** — when a newsletter advertises its own RSS/Atom/JSON feed via `<link rel="alternate">` in the email HTML, KTN surfaces it in the admin (a "Native feeds" chip group on the email detail page, a dashboard pill, and a dismissable banner) and on the REST API (`nativeFeeds` field), so you can subscribe to the source directly
|
||||||
- Email attachments stored in Cloudflare R2 and exposed as RSS enclosures (optional)
|
- 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
|
||||||
@@ -40,9 +50,9 @@ Two ingestion methods are supported — pick one or use both:
|
|||||||
|
|
||||||
Common path:
|
Common path:
|
||||||
|
|
||||||
1. Incoming email arrives at `user@yourdomain.com`.
|
1. Incoming email arrives at `apple.mountain.42@yourdomain.com` (the feed's inbound address).
|
||||||
2. The Worker resolves the feed from the recipient address and stores the email in KV.
|
2. The Worker resolves the feed from the recipient address (via the `inbound:` index) and stores the email in KV.
|
||||||
3. `https://yourdomain.com/rss/:feedId` renders RSS from stored items.
|
3. `https://yourdomain.com/rss/<opaque-feed-id>` renders RSS from stored items — note the feed id is a separate opaque token, not the inbound address.
|
||||||
4. `/admin` provides feed management and email deletion.
|
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.
|
5. `https://yourdomain.com/` shows a public status page with monitoring counters and a link to the admin.
|
||||||
|
|
||||||
@@ -50,8 +60,10 @@ Main routes:
|
|||||||
|
|
||||||
- `src/lib/cloudflare-email.ts`: Cloudflare Email Workers ingestion
|
- `src/lib/cloudflare-email.ts`: Cloudflare Email Workers ingestion
|
||||||
- `src/routes/inbound.ts`: ForwardEmail webhook ingestion
|
- `src/routes/inbound.ts`: ForwardEmail webhook ingestion
|
||||||
- `src/routes/rss.ts`: RSS rendering
|
- `src/routes/rss.ts`: RSS rendering (with conditional-GET / ETag support)
|
||||||
- `src/routes/atom.ts`: Atom feed rendering
|
- `src/routes/atom.ts`: Atom feed rendering (with conditional-GET / ETag support)
|
||||||
|
- `src/routes/json.ts`: JSON Feed rendering
|
||||||
|
- `src/routes/opml.ts`: OPML export of all feeds (admin-protected, mounted at `/admin/opml`)
|
||||||
- `src/routes/files.ts`: attachment file serving from R2
|
- `src/routes/files.ts`: attachment file serving from R2
|
||||||
- `src/routes/admin.tsx`: 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/routes/api/`: versioned REST API + OpenAPI spec/docs (`/api/v1/*`, `/api/openapi.json`, `/api/docs`)
|
||||||
@@ -131,6 +143,7 @@ Then enable email ingestion (Cloudflare Email Workers or ForwardEmail) and open
|
|||||||
- When using Option B (ForwardEmail), inbound webhook access is IP-restricted to ForwardEmail MX sources.
|
- When using Option B (ForwardEmail), inbound webhook access is IP-restricted to ForwardEmail MX sources.
|
||||||
- Admin auth uses a signed, `HttpOnly`, `Secure`, `SameSite=Strict` cookie.
|
- Admin auth uses a signed, `HttpOnly`, `Secure`, `SameSite=Strict` cookie.
|
||||||
- Admin responses are `no-store` to avoid cache leakage.
|
- Admin responses are `no-store` to avoid cache leakage.
|
||||||
|
- Feed, entry, and attachment responses send `X-Robots-Tag: noindex`, and `/robots.txt` disallows `/rss`, `/atom`, `/entries`, `/files`, and `/admin`, so private feeds and emails are kept out of search engines.
|
||||||
- For high-value feeds, set `Allowed senders` so only known sender addresses/domains are accepted.
|
- For high-value feeds, set `Allowed senders` so only known sender addresses/domains are accepted.
|
||||||
- You should use a strong admin password and rotate periodically.
|
- You should use a strong admin password and rotate periodically.
|
||||||
- All secret comparisons (admin password, proxy secret) use constant-time comparison to prevent timing attacks.
|
- All secret comparisons (admin password, proxy secret) use constant-time comparison to prevent timing attacks.
|
||||||
|
|||||||
@@ -2,52 +2,251 @@
|
|||||||
|
|
||||||
Feature gaps identified by comparing with [kill-the-newsletter](https://github.com/leafac/kill-the-newsletter).
|
Feature gaps identified by comparing with [kill-the-newsletter](https://github.com/leafac/kill-the-newsletter).
|
||||||
|
|
||||||
|
> **Origin tags.** Every idea carries an `_origin:_` reference so we can notify the source when it ships.
|
||||||
|
>
|
||||||
|
> - `ktn#N` → a [kill-the-newsletter issue/PR](https://github.com/leafac/kill-the-newsletter/issues) — **comment there when implemented** to close the loop with the requester.
|
||||||
|
> - A tool/spec URL → external inspiration (a competitor or standard); no individual to notify, but the rationale is traceable.
|
||||||
|
> - `internal` → our own design/code audit; no external requester.
|
||||||
|
|
||||||
|
> **Priority × size.** Each idea is tagged `Pn·Size`. Priority by user value: **P1** (high) / **P2** (medium) / **P3** (nice-to-have). Effort by implementation size: **S** (hours) / **M** (~1–2 days) / **L** (several days) / **XL** (week+). Done items keep the tag as a retrospective estimate.
|
||||||
|
|
||||||
## Quick wins
|
## Quick wins
|
||||||
|
|
||||||
- [x] **Author field in RSS entries** — expose the `from` address as `<author>` in each RSS `<item>`. The value is already stored in KV, just not rendered in the feed XML.
|
- [x] `P1·S` **Author field in RSS entries** — expose the `from` address as `<author>` in each RSS `<item>`. The value is already stored in KV, just not rendered in the feed XML. — _origin: [ktn#102](https://github.com/leafac/kill-the-newsletter/issues/102) (ktn CHANGELOG 2.0.6 "author to entry")_
|
||||||
|
|
||||||
- [x] **HTML view for individual entries** — serve each email as an HTML page at e.g. `/entries/:feedId/:timestamp`. Useful for reading emails outside a feed reader and for debugging. kill-the-newsletter serves these at `/feeds/{feedId}/entries/{entryId}.html` with a Content-Security-Policy header.
|
- [x] `P1·M` **HTML view for individual entries** — serve each email as an HTML page at e.g. `/entries/:feedId/:timestamp`. Useful for reading emails outside a feed reader and for debugging. kill-the-newsletter serves these at `/feeds/{feedId}/entries/{entryId}.html` with a Content-Security-Policy header. — _origin: upstream alternate-HTML view; gives each item a valid URL ([ktn#17](https://github.com/leafac/kill-the-newsletter/issues/17), [ktn#40](https://github.com/leafac/kill-the-newsletter/issues/40))_
|
||||||
|
|
||||||
- [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] `P2·S` **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). — _origin: [ktn#43](https://github.com/leafac/kill-the-newsletter/issues/43) (ktn CHANGELOG 2.0.5)_
|
||||||
|
|
||||||
- [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.
|
- [x] `P2·S` **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. — _origin: internal (404 fix); related [ktn#131](https://github.com/leafac/kill-the-newsletter/issues/131)_
|
||||||
|
|
||||||
## 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] `P2·M` **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. — _origin: upstream size limit (ktn CHANGELOG 2.0.8); related [ktn#59](https://github.com/leafac/kill-the-newsletter/issues/59), [ktn#115](https://github.com/leafac/kill-the-newsletter/issues/115)_
|
||||||
|
|
||||||
- [x] **Atom feed format** — expose feeds as Atom (`application/atom+xml`) in addition to or instead of RSS 2.0. Atom has better native support for HTML content and author metadata.
|
- [x] `P1·M` **Atom feed format** — expose feeds as Atom (`application/atom+xml`) in addition to or instead of RSS 2.0. Atom has better native support for HTML content and author metadata. — _origin: upstream (Atom-native product) / internal parity_
|
||||||
|
|
||||||
- [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] `P3·M` **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. — _origin: internal_
|
||||||
|
|
||||||
- [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] `P2·M` **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. — _origin: [ktn#92](https://github.com/leafac/kill-the-newsletter/issues/92) (ktn CHANGELOG 2.0.6/2.0.7)_
|
||||||
|
|
||||||
- [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`.
|
- [x] `P2·M` **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`. — _origin: internal ([RFC 8058](https://www.rfc-editor.org/rfc/rfc8058.txt))_
|
||||||
|
|
||||||
## 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] `P1·L` **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}`. — _origin: [ktn#66](https://github.com/leafac/kill-the-newsletter/issues/66), [ktn#86](https://github.com/leafac/kill-the-newsletter/issues/86) (ktn CHANGELOG 2.0.5)_
|
||||||
|
|
||||||
- [x] **WebSub (PubSubHubbub) push notifications** — notify subscribers in real time when a new email arrives, instead of requiring them to poll the feed. Requires either integrating a public WebSub hub or implementing the hub protocol directly.
|
- [x] `P2·L` **WebSub (PubSubHubbub) push notifications** — notify subscribers in real time when a new email arrives, instead of requiring them to poll the feed. Requires either integrating a public WebSub hub or implementing the hub protocol directly. — _origin: [ktn#68](https://github.com/leafac/kill-the-newsletter/issues/68) (ktn CHANGELOG 2.0.4)_
|
||||||
|
|
||||||
- [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] `P2·S` **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. — _origin: upstream parity (ktn CHANGELOG 2.0.3) / internal_
|
||||||
|
|
||||||
- [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.
|
- [x] `P2·L` **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. — _origin: [ktn#43](https://github.com/leafac/kill-the-newsletter/issues/43)_
|
||||||
|
|
||||||
- [ ] **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.
|
- [ ] `P3·XL` **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. — _origin: internal; same race behind [ktn#6](https://github.com/leafac/kill-the-newsletter/issues/6), [ktn#31](https://github.com/leafac/kill-the-newsletter/issues/31)_
|
||||||
|
|
||||||
|
## From upstream issues/PRs (2026-05-24 review)
|
||||||
|
|
||||||
|
Gaps found by reading every open/closed issue + PR on [kill-the-newsletter](https://github.com/leafac/kill-the-newsletter/issues). These are requests we do **not** yet satisfy (many other recurring requests — dark mode, copy buttons, favicon, expiration, attachments, API, WebSub, sender-in-author — we already cover).
|
||||||
|
|
||||||
|
- [x] `P1·M` **Subscription confirmation handling** — _the single most recurring upstream request_ ([#5](https://github.com/leafac/kill-the-newsletter/issues/5), [#23](https://github.com/leafac/kill-the-newsletter/issues/23), [#57](https://github.com/leafac/kill-the-newsletter/issues/57), [#73](https://github.com/leafac/kill-the-newsletter/issues/73), [#89](https://github.com/leafac/kill-the-newsletter/issues/89), [#95](https://github.com/leafac/kill-the-newsletter/issues/95), [#97](https://github.com/leafac/kill-the-newsletter/issues/97)). Newsletters require a "click to confirm your email" step; users can't easily find/click the link buried in a feed reader. Our admin already lists emails, but nothing **surfaces** the confirmation link or shows the first email inline right after feed creation. Low effort, high payoff (admin UX in `src/routes/admin/feeds.tsx` + maybe extract candidate confirm links during ingestion in `src/application/email-processor.ts`). — **Shipped:** v1 detects confirmation emails at ingestion (multilingual keyword + link scoring) and surfaces the link in the admin (detail section, list badge, dashboard pill, emails-page banner + dismiss); post-create now lands on the feed's emails page. v1 does no outbound request; server on-detect actions deferred (see below).
|
||||||
|
|
||||||
|
- [ ] `P2·M` **Confirmation on-detect server action (none / autoclick / forward)** — extend the shipped confirmation detection with a server-configured action via an env var (default `none`): `autoclick` = follow the detected confirm link server-side from the worker (⚠ guard SSRF: http(s) only, block internal/private IP ranges, timeout, no redirect to non-http schemes); `forward` = forward the original email to `FALLBACK_FORWARD_ADDRESS`. Touches `src/application/email-processor.ts`, `Env` (`src/types/index.ts`), `src/infrastructure/cloudflare-email.ts`. — _origin: internal (juherr)_
|
||||||
|
|
||||||
|
- [x] `P1·M` **Separate write (email) / read (feed) IDs** — _most-requested privacy gap, still open upstream_ ([#114](https://github.com/leafac/kill-the-newsletter/issues/114), [#93](https://github.com/leafac/kill-the-newsletter/issues/93), [#75](https://github.com/leafac/kill-the-newsletter/issues/75)). The two identities are now decoupled: `FeedId` is an **opaque random token** (`FeedId.generate()` → 22-char base64url) used as the KV storage key and the public read id (`/rss/:feedId`), while the inbound address is a separate `MailboxId` VO (`noun.noun.NN`, the old format) resolved to its feed **only at reception** via a new `inbound:<mailboxId>` secondary index (`src/infrastructure/feed-repository.ts` `resolveInbound`). `MailboxId.parse` owns the untrusted-input boundary (moved off `FeedId`); the mailbox lives on `FeedState.mailboxId` / `mailbox_id` and is projected into `feeds:list`. Reading `/rss/<noun.noun.NN>` 404s and no public feed output contains the inbound address. Pre-release, so no migration/backward-compat. — _origin: [ktn#114](https://github.com/leafac/kill-the-newsletter/issues/114), [ktn#93](https://github.com/leafac/kill-the-newsletter/issues/93), [ktn#75](https://github.com/leafac/kill-the-newsletter/issues/75)_
|
||||||
|
|
||||||
|
- [ ] `P2·S` **Rotate the inbound mailbox and/or feed id** — _follow-up to the write/read separation above_. Now that the inbound address (`MailboxId`) and the read id (`FeedId`) are decoupled, offer an admin + REST action to **re-mint** either one to revoke a leaked subscribe address or a shared feed URL. Rotating the mailbox: generate a new `MailboxId`, write the new `inbound:<new>` index, delete the old; rotating the read id is heavier (it's the KV storage key — would require re-keying `feed:<id>:*`, so prefer rotating only the mailbox first). Touch `feed-service.ts`, `feed-repository.ts`, admin UI, `api/index.ts`. — _origin: internal (privacy)_
|
||||||
|
|
||||||
|
- [ ] `P2·M` **Proxy/prefetch remote images** ([#69](https://github.com/leafac/kill-the-newsletter/issues/69)). We already proxy inline `cid:` images via R2, but remote `<img src="https://…">` stay remote → tracking pixels fire on read. Extend `src/infrastructure/html-processor.ts` to rewrite remote image src through a worker proxy/cache endpoint (reuse the R2 + Cache API pattern from favicons).
|
||||||
|
|
||||||
|
- [ ] `P3·M` **Tracking-link redirect resolver** ([#36](https://github.com/leafac/kill-the-newsletter/issues/36)). Unwrap marketing/tracking URLs (e.g. `click.convertkit-mail…`) to their final destination so the redirect/tracking happens server-side (or is stripped) instead of from the reader. Lives in `src/infrastructure/html-processor.ts`. Mind SSRF/abuse surface when following redirects.
|
||||||
|
|
||||||
|
- [ ] `P2·S` **Strip-styles / plaintext rendering option** ([#74](https://github.com/leafac/kill-the-newsletter/issues/74), [#119](https://github.com/leafac/kill-the-newsletter/issues/119)). Some readers render newsletter HTML/CSS poorly. Offer an opt-in to strip `<style>` + inline styles (keeping links), or to prefer the `text/plain` part. Per-feed setting + `src/infrastructure/html-processor.ts`.
|
||||||
|
|
||||||
|
- [x] `P2·S` **Optional sender in entry title** ([#123 — open PR upstream](https://github.com/leafac/kill-the-newsletter/pull/123), [#124](https://github.com/leafac/kill-the-newsletter/issues/124)). We already emit `<author>`, but some users want `[Sender] Subject` as the entry title for at-a-glance scanning in the reader. Per-feed toggle + `src/infrastructure/feed-generator.ts`. — **Shipped:** per-feed `senderInTitle` flag (domain `FeedState.senderInTitle` ↔ `FeedConfig.sender_in_title`); when set, `buildFeed` prefixes each entry title with `[Sender]` (display name, falling back to the email address). Toggle exposed as an admin edit-form checkbox and on the REST API (`FeedCreate`/`FeedUpdate`/`Feed` schemas).
|
||||||
|
|
||||||
|
- [x] `P2·S` **Detect a newsletter's native Atom/RSS feed** — _top item on upstream's own [TODO](https://github.com/leafac/kill-the-newsletter/blob/main/TODO.md), not yet built there_. When an incoming email's HTML contains `<link rel="alternate" type="application/atom+xml">` (or `application/rss+xml`), surface it: "this newsletter already publishes a feed — subscribe to it directly instead." We already parse HTML with linkedom in `src/infrastructure/html-processor.ts`, so detection is cheap; store the discovered URL on the feed and show it in the admin UI / a feed entry. A genuine differentiator — we'd ship it before upstream. — **Shipped:** per-sender detection of `<link rel="alternate">` (Atom, RSS, JSON Feed) in incoming email HTML at ingestion (`src/domain/native-feed.ts` pure detector, wired in `src/application/email-processor.ts`); discovered feeds stored as `nativeFeeds: Record<string, NativeFeed[]>` on the feed metadata; admin detail page shows a "Native feeds" copyable chip group per sender, feed dashboard shows a `pill-native` ("Native feed available") pill, and a dismissable banner on the emails page prompts subscribing at the source (`nativeFeedDismissed` flag); read-only `nativeFeeds: [{ url, type }]` array on the REST `FeedSchema` (`GET`/`POST`/`PATCH /api/v1/feeds`); no change to public RSS/Atom/JSON feed output.
|
||||||
|
|
||||||
|
- [x] `P1·S` **`X-Robots-Tag: none` on feed + entry routes** ([#33](https://github.com/leafac/kill-the-newsletter/issues/33)). Private feeds/emails should never be search-indexed. Upstream sets `X-Robots-Tag: none` on its responses; we set a CSP on `/entries` but **no** robots header anywhere. Add `X-Robots-Tag: noindex` to `rss.ts`, `atom.ts`, `entries.ts`, `files.ts` (and optionally a `/robots.txt`). Low effort, real privacy gap.
|
||||||
|
|
||||||
|
## From similar projects & RSS readers (2026-05-24 review)
|
||||||
|
|
||||||
|
Ideas from competitors (Feedbin, Readwise Reader, Inoreader, Omnivore, LetterFeed, Mailbrew, mail2rss) and from what leading readers (NetNewsWire, Reeder, Feedly, Inoreader, NewsBlur, Miniflux, FreshRSS) can consume. Deduplicated against the upstream-issues section above. Tagged **[table-stakes]** vs **[differentiating]**.
|
||||||
|
|
||||||
|
### Feed-output enrichments (small XML wins — we use the `feed` lib, which already emits `content:encoded`, `atom:link rel="self"`, stable `<guid>`)
|
||||||
|
|
||||||
|
- [x] `P2·S` **JSON Feed endpoint** `GET /json/:feedId` **[differentiating, cheap]** — the `feed` lib's `.json1()` (emits JSON Feed v1) wired via `generateJsonFeed` in `src/infrastructure/feed-generator.ts`, served at `/json/:feedId` (`src/routes/json.ts`) with `Content-Type: application/feed+json` + WebSub hub `Link`. All three formats cross-link via `feedLinks`. Natively consumed by NetNewsWire, Reeder, NewsBlur, Feedly. — _origin: [JSON Feed 1.1 spec](https://www.jsonfeed.org/version/1.1/) (reader ecosystem)_
|
||||||
|
|
||||||
|
- [ ] `P3·S` **Upgrade JSON Feed output to v1.1** **[correctness, niche]** — our `/json/:feedId` emits `version: "https://jsonfeed.org/version/1"` because the `feed` lib's `.json1()` only implements v1, and the upstream request to bump it was **closed as _not planned_** ([jpmonette/feed#139](https://github.com/jpmonette/feed/issues/139)). So a true v1.1 feed needs a small post-process pass on the `.json1()` object in `generateJsonFeed` (`src/infrastructure/feed-generator.ts`): set `version` to `https://jsonfeed.org/version/1.1`, and apply the [v1.1 changes](https://www.jsonfeed.org/version/1.1/#changes-a-name-changes-a) — promote the deprecated top-level/item `author` to `authors` (array), and add the top-level `language` field. Low value (every reader still parses v1) but cheap and removes a spec-compliance footnote. — _origin: [jpmonette/feed#139 (closed, not planned)](https://github.com/jpmonette/feed/issues/139); [JSON Feed 1.1 spec](https://www.jsonfeed.org/version/1.1/)_
|
||||||
|
|
||||||
|
- [ ] `P2·M` **Per-item `<category>` + per-feed tags/categories** **[differentiating]** — we set no categories today. Tag entries by sender (or a user-set feed category) so readers (Inoreader, Feedly, NewsBlur) can filter/mute subsets. Pairs with the filtering item below; touches `FeedState`, `feed-generator.ts`. — _origin: [RSS best practices (kevincox)](https://kevincox.ca/2022/05/06/rss-feed-best-practices/); Inoreader/Feedly filtering_
|
||||||
|
|
||||||
|
- [ ] `P3·S` **Reader cadence hints: `<ttl>` + `sy:updatePeriod`/`sy:updateFrequency`** **[table-stakes, niche]** — advertise the feed's real update rhythm so pollers (FreshRSS, Miniflux, Inoreader) back off; complements our WebSub push. Support is uneven, so keep it as a hint alongside WebSub. Also advertise the WebSub hub link _inside_ the XML (`<atom:link rel="hub">`), not only the HTTP `Link` header. — _origin: [FreshRSS TTL #6721](https://github.com/FreshRSS/FreshRSS/issues/6721)_
|
||||||
|
|
||||||
|
- [ ] `P2·M` **Media RSS lead image (`<media:content>`/`<media:thumbnail>`)** **[differentiating]** — extract the first image of each email as a thumbnail so card/story layouts (Feedly, Inoreader, NewsBlur) show a preview. The `feed` lib doesn't emit Media RSS, so this needs post-processing or a custom serializer. — _origin: [Media RSS spec](https://www.rssboard.org/media-rss); Feedly/Inoreader consume it_
|
||||||
|
|
||||||
|
### Ingestion & processing
|
||||||
|
|
||||||
|
- [ ] `P2·M` **Keyword/subject filtering rules (keep/drop)** **[differentiating]** — we already have _sender_ allow/block (`SenderPolicy`), but no content rules. Add per-feed keep/drop rules by subject or body keyword (Inoreader/Omnivore-style), applied in `src/application/email-processor.ts` at the same gate as the sender policy. — _origin: [Inoreader rules](https://www.inoreader.com/blog/2020/02/declutter-your-inbox-subscribe-to-email-newsletters-straight-into-inoreader.html); Omnivore filters_
|
||||||
|
|
||||||
|
- [ ] `P2·M` **Confirmation-code relay** **[differentiating]** — _extends the "Subscription confirmation handling" item above_. Readwise Reader auto-detects "reply with code X" / "click to confirm" emails and surfaces (or relays) the code. Beyond just showing the link: detect the confirm pattern and present a one-tap action in admin. — _origin: [Readwise Reader docs](https://docs.readwise.io/reader/docs/faqs/email-newsletters); also [ktn#89](https://github.com/leafac/kill-the-newsletter/issues/89) (reply-to-confirm)_
|
||||||
|
|
||||||
|
- [ ] `P3·XL` **IMAP-pull ingestion option** **[differentiating for self-hosters]** — alternative to the ForwardEmail/Cloudflare-Email webhook: poll an existing IMAP mailbox and route allow-listed senders to feeds (LetterFeed model). Big lift on a Worker (needs a scheduled fetch + IMAP over a TCP socket / external relay); evaluate feasibility before committing. — _origin: [LetterFeed](https://github.com/LeonMusCoden/LetterFeed); also [ktn#26](https://github.com/leafac/kill-the-newsletter/issues/26) (use IMAP instead of hosting a mail server)_
|
||||||
|
|
||||||
|
### Reading experience
|
||||||
|
|
||||||
|
- [x] `P2·S` **OPML export** `GET /admin/opml` **[table-stakes, easy]** — export all feeds as an OPML 2.0 outline (`<outline type="rss" xmlUrl=…>` per feed, XML-attr-escaped) so users can bulk-import every feed into their reader in one shot. Mounted on the admin Hono app (inherits the admin auth middleware) rather than public, because the registry lists every feed's RSS URL — a public endpoint would leak them all. Returns `Content-Disposition: attachment; filename="feeds.opml"`. Implemented in `src/routes/opml.ts` over `FeedRepository.listFeeds()`. — _origin: reader ecosystem ([NetNewsWire](https://github.com/Ranchero-Software/NetNewsWire/)); Feedbin OPML export_
|
||||||
|
|
||||||
|
- [ ] `P2·L` **Full-text search across received emails** **[differentiating]** — admin-side search over subjects + bodies (Omnivore/Feedbin have this). On KV this means an index or scan; consider scope (subject-only first) before building. — _origin: [Omnivore](https://www.timeatlas.com/omnivore-newsletters/); Feedbin search_
|
||||||
|
|
||||||
|
- [ ] `P3·L` **Readability / clean-text view toggle** **[differentiating]** — _related to "strip-styles" above but distinct_: run a readability extraction (article body only) as an opt-in per feed, remembered per sender (Readwise pattern), rather than just stripping CSS. — _origin: [Readwise Reader feed docs](https://docs.readwise.io/reader/docs/faqs/feed)_
|
||||||
|
|
||||||
|
### Greenfield differentiators
|
||||||
|
|
||||||
|
- [ ] `P2·L` **AI per-newsletter summarization** **[differentiating]** — generate a short TL;DR per email (or a daily digest summary) using Cloudflare Workers AI (no new vendor, no key to manage). Almost no competitor ships this well. Add an `AI` binding + an opt-in per-feed flag; render the summary atop the entry content. — _origin: [Precis](https://github.com/leozqin/precis), [babarot AI reader](https://dev.to/babarot/i-built-a-self-hosted-rss-reader-with-ai-summarization-translation-and-an-mcp-server-316c)_
|
||||||
|
|
||||||
|
- [ ] `P3·L` **Digest / bundling mode** **[differentiating]** — for low-volume feeds, batch N emails into a single periodic digest entry (Mailbrew model) so readers aren't flooded. Per-feed cadence setting; runs on the existing cron. — _origin: [Mailbrew](https://www.readless.app/blog/mailbrew-pricing-2026)_
|
||||||
|
|
||||||
|
## Robustness, delivery, auth & integrations (2026-05-24 deep dig)
|
||||||
|
|
||||||
|
Verified-missing in our code, deduplicated against the sections above. From a code audit + a sweep of niche/recent tools (Precis, changedetection.io+Apprise, MailCast email-to-podcast, FreshRSS/Miniflux token auth, RFC 5005, postly dedup).
|
||||||
|
|
||||||
|
### Delivery / bandwidth
|
||||||
|
|
||||||
|
- [x] `P2·S` **Conditional GET on feeds (ETag + Last-Modified + 304)** **[table-stakes, easy]** — `rss.ts`/`atom.ts` now emit a strong `ETag` (`"<format>-<feedId>-<count>-<maxReceivedAt>"`) and `Last-Modified` (newest `receivedAt`), and return `304 Not Modified` on matching `If-None-Match`/`If-Modified-Since` before generating any XML. Validators are computed from the loaded `FeedData` (not the rendered bytes) in `src/infrastructure/http-cache.ts` (`computeFeedValidators`/`isNotModified`/`notModifiedResponse`), shared by both routes; rss vs atom get distinct ETags via the format prefix. Cuts bandwidth for every polling reader. — _origin: internal code audit ([RFC 9110 conditional requests](https://www.rfc-editor.org/rfc/rfc9110#name-conditional-requests))_
|
||||||
|
|
||||||
|
- [ ] `P3·L` **RFC 5005 paged / archived feeds** **[differentiating, niche]** — readers only ever see the capped current window; older entries vanish. Mark the subscription document `fh:complete` and expose `prev-archive` pages so readers can backfill history. Pairs naturally with our expiring-feed model (an expired feed = a sealed archive). ([RFC 5005](https://www.rfc-editor.org/rfc/rfc5005.html))
|
||||||
|
|
||||||
|
### Ingestion robustness
|
||||||
|
|
||||||
|
- [x] `P1·M` **Duplicate-send dedup** **[differentiating]** — a newsletter resent (or delivered twice) is now stored once. `storeEmail` (`src/application/email-processor.ts`) computes the `Message-ID` (case-insensitive header lookup) and a SHA-256 of normalized `subject+content`, then asks the aggregate `feed.hasDuplicate(messageId, dedupHash)` (`src/domain/feed.aggregate.ts`): primary match on `Message-ID`, fallback to the content hash when neither side has a Message-ID. A duplicate is a successful no-op (`{ ok: true }`, nothing stored/dispatched) and bumps a new `emails_deduplicated` counter (status page + `/api/v1/stats`). `EmailMetadata` gained additive `messageId?`/`dedupHash?` fields, so pre-feature entries never false-match. Fixes the upstream "duplicate posts" complaint ([#31](https://github.com/leafac/kill-the-newsletter/issues/31), [#6](https://github.com/leafac/kill-the-newsletter/issues/6)).
|
||||||
|
|
||||||
|
- [ ] `P3·M` **Calendar (.ics) invite extraction** **[differentiating, novel]** — no email→feed tool does this. Detect `text/calendar` parts, parse the event, and surface it in the entry (summary + an `.ics` enclosure / add-to-calendar link). Useful for event/booking newsletters. — _origin: internal (novel; no external requester)_
|
||||||
|
|
||||||
|
- [x] `P2·S` **`FALLBACK_FORWARD_ADDRESS` — catch-all fallback forwarding** **[differentiating for self-hosters]** — today `handleCloudflareEmail` silently drops (just `logger.warn`) any address that isn't a feed, so you can't point a domain's _catch-all_ at KTN without swallowing your personal mail. Add an optional `FALLBACK_FORWARD_ADDRESS` env var: after `processEmail`, forward non-feed mail to it based on `result.reason` — **forward** on `invalid_address` (not a `noun.noun.NN` address) and `feed_not_found` (well-formed but no such feed); **drop** on `feed_expired` and `sender_blocked` (don't leak a newsletter to the fallback box); nothing on `ok`. Unset env → current drop+log behavior unchanged. The destination must be a _verified_ Cloudflare Email Routing address or `message.forward()` fails; `await` it in a `try/catch` (`logger.warn` on failure), forward at most once. Touch: `Env` (`src/types/index.ts`), `src/infrastructure/cloudflare-email.ts` (`result.reason` already available), `cloudflare-email.test.ts` (forwarded for `feed_not_found`/`invalid_address` when set; not for `feed_expired`/`sender_blocked`; not when unset), `wrangler-example.toml` (commented `# FALLBACK_FORWARD_ADDRESS` under `[vars]`), `INSTALL.md` ("Catch-all fallback forwarding" section: verified-destination prerequisite + use case). — _origin: internal (juherr — self-host on juherr.dev catch-all); generic "use KTN as my domain's catch-all"_
|
||||||
|
|
||||||
|
### Auth & privacy
|
||||||
|
|
||||||
|
- [ ] `P2·M` **Scoped / multiple API tokens (admin-managed)** **[security]** — the REST API currently accepts the single `ADMIN_PASSWORD` as the bearer (`src/infrastructure/auth.ts`). Add named, independently-revocable tokens (optionally read-only or feed-scoped) that the admin can **create, list, and revoke from the admin UI** (stored hashed in KV, shown once on creation), so automation doesn't hold the master password. The bearer middleware then accepts either `ADMIN_PASSWORD` or any active token; revoking a token is instant. — _origin: internal security audit; juherr (manage API tokens)_
|
||||||
|
|
||||||
|
- [ ] `P2·S` **Change the admin password from the UI** **[security]** — today `ADMIN_PASSWORD` is a Worker secret set via `wrangler secret put`, so rotating it means a redeploy. Add an admin-UI action (current password + new password) that stores a hashed password override in KV (e.g. `admin:password`); `src/infrastructure/auth.ts` checks the KV override first and falls back to the `ADMIN_PASSWORD` env secret when unset, so existing installs keep working and the env var becomes the bootstrap/reset default. Pairs with the API-tokens item (same auth surface). — _origin: internal; juherr (change admin password)_
|
||||||
|
|
||||||
|
- [ ] `P3·XL` **Multi-user support** **[differentiating]** — today the app is single-admin (one `ADMIN_PASSWORD` guards all feeds; `feeds:list` is global). Support multiple user accounts, each owning a private subset of feeds: per-user credentials/sessions, feed ownership on `FeedState`, per-user feed registry (scope `feeds:list` by owner), and admin scoping across the admin UI + REST API. Big lift — touches auth, the feed registry/key schema, and every admin/API route; depends on the change-password and API-token items as the auth foundation. ⚠ Note the off-Cloudflare epic currently lists "Multi-tenant / multi-domain admin" as out of scope — reconcile that scope boundary before committing. — _origin: internal; juherr (multi-user)_
|
||||||
|
|
||||||
|
- [ ] `P2·M` **Token-protected private feeds** **[security, differentiating]** — `/rss` and `/atom` are public-by-obscurity (anyone with the URL reads it). Offer an opt-in `?token=…` (FreshRSS-style) or HMAC-signed, optionally expiring URL (fits our expiring-feed model) so a feed can be truly private and shareable without leaking the inbound address. Complements the _separate write/read IDs_ item above. ([FreshRSS](https://freshrss.github.io/FreshRSS/en/admins/09_AccessControl.html))
|
||||||
|
|
||||||
|
### Push & integrations
|
||||||
|
|
||||||
|
- [ ] `P2·L` **Push new items to chat (per-feed)** **[differentiating]** — for users who don't run a reader, push each new email to Telegram / Discord / ntfy / a generic webhook, routed per feed, instant-vs-digest toggle (Precis / changedetection.io+Apprise pattern). Fires from the existing event dispatcher (`src/application/feed-events.ts`) via `ctx.waitUntil`. ([Precis](https://github.com/leozqin/precis))
|
||||||
|
|
||||||
|
### Novel / stretch (Cloudflare-native)
|
||||||
|
|
||||||
|
- [ ] `P3·M` **MCP server over your feeds** **[differentiating, novel]** — expose feeds/emails to AI agents via a Model Context Protocol endpoint on the Worker, so an assistant can read/search a user's newsletters. Cheap to add on a Worker, genuinely new in this space. — _origin: [babarot AI reader + MCP](https://dev.to/babarot/i-built-a-self-hosted-rss-reader-with-ai-summarization-translation-and-an-mcp-server-316c)_
|
||||||
|
|
||||||
|
- [ ] `P3·L` **Email-to-podcast (TTS audio enclosure)** **[differentiating, novel]** — opt-in: synthesize each newsletter to audio (Cloudflare Workers AI TTS), store in R2, attach as an `<enclosure>` so the feed doubles as a private podcast. Reframes feed item = audio. ([prior art](https://github.com/tcanfarotta/email-to-podcast-rss))
|
||||||
|
|
||||||
|
> Framing notes (no code, worth surfacing in docs/landing): we already deliver several things competitors charge for — **full-body capture bypasses Substack/"read more" truncation** (we ingest the email, not the scraped page), and each feed's inbound address is effectively a **burnable alias** (delete the feed + RFC 8058 one-click unsubscribe already kills the sender). Market these explicitly.
|
||||||
|
|
||||||
|
## Feed namespaces & reader-rendering correctness (2026-05-24 deep dig)
|
||||||
|
|
||||||
|
Two final angles: (1) less-common RSS/Atom namespaces that visibly improve feeds in real readers, and (2) generator-side correctness fixes that stop feeds breaking in self-hosted readers. The `feed` lib emits `content:encoded`/`atom:link rel=self`/stable `<guid>` but does **not** handle the items below — they need its custom-namespace/extension hooks or a post-process pass.
|
||||||
|
|
||||||
|
### Namespaces worth emitting
|
||||||
|
|
||||||
|
- [ ] `P2·S` **WebFeeds branding (`webfeeds:accentColor`, `webfeeds:icon`, `webfeeds:logo`, `webfeeds:cover`)** **[differentiating, high visible payoff]** — Feedly puts your SVG logo on every story and recolors links to your accent color. We already derive a per-feed favicon; add an accent + logo for branded-looking feeds. — _origin: [Working With Web Feeds (CSS-Tricks)](https://css-tricks.com/working-with-web-feeds-its-more-than-rss/)_
|
||||||
|
|
||||||
|
- [ ] `P2·M` **Media RSS thumbnail/credit (`media:thumbnail`, `media:description`, `media:credit`)** **[differentiating]** — richer than the lead-image item above: gives readers a card image, alt text, and attribution. — _origin: [Media RSS spec](https://www.rssboard.org/media-rss)_
|
||||||
|
|
||||||
|
- [ ] `P3·S` **Dublin Core `dc:creator`** **[niche, cheap]** — credits the newsletter sender **without** an email address (RSS `<author>` requires one); safer than a synthetic `noreply@`. — _origin: [RSS Best Practices Profile](https://www.rssboard.org/rss-profile), [mod_dublincore](https://www.oreilly.com/library/view/developing-feeds-with/0596008813/re08.html)_
|
||||||
|
|
||||||
|
- [ ] `P3·M` **Podcast namespace (`itunes:*` + `podcast:transcript`/`chapters`)** **[stretch]** — only if the email-to-podcast item ships; turns the audio feed into a real Podcasting 2.0 feed. — _origin: [Podcast Namespace](https://podcasting2.org/docs/podcast-namespace)_
|
||||||
|
|
||||||
|
### Reader-rendering correctness (turn these into hardening tasks)
|
||||||
|
|
||||||
|
- [x] `P1·S` **Rewrite relative URLs in content to absolute** **[correctness]** — most readers ignore `xml:base`; relative `src`/`href` in `content:encoded` break in Miniflux/NetNewsWire. Absolutize every link/image before emitting (`src/infrastructure/html-processor.ts`). — _origin: [W3C ContainsRelRef](https://validator.w3.org/feed/docs/warning/ContainsRelRef.html)_
|
||||||
|
|
||||||
|
- [x] `P1·S` **Promote lazy-loaded images (`data-src` → `src`, strip `loading="lazy"`)** **[correctness]** — newsletters with lazy images render blank in readers. — _origin: [Hugo RSS & lazy images](https://brainbaking.com/post/2021/01/hugo-rss-feeds-and-lazy-image-loading/)_
|
||||||
|
|
||||||
|
- [x] `P1·S` **Strip XML-illegal control chars + guarantee valid UTF-8** **[correctness]** — a single bad codepoint fails the _whole_ feed parse in strict readers (newsboat). Sanitize before serialization. — _origin: [newsboat #2328](https://github.com/newsboat/newsboat/issues/2328), [W3C SAXError](https://validator.w3.org/feed/docs/error/SAXError.html); upstream hit this too ([ktn#1](https://github.com/leafac/kill-the-newsletter/issues/1) cyrillic, [ktn#9](https://github.com/leafac/kill-the-newsletter/issues/9) invalid XML char)_
|
||||||
|
|
||||||
|
- [ ] `P2·S` **Real `enclosure` byte length + correct type (never `length="0"`)** **[correctness]** — zero/missing length makes podcast clients reject the enclosure; use the actual R2 object size. — _origin: [AzuraCast #7809](https://github.com/AzuraCast/AzuraCast/issues/7809)_
|
||||||
|
|
||||||
|
- [x] `P1·S` **Plain-text `<title>` (strip HTML, decode entities)** **[correctness]** — raw tags in titles show literally in readers; keep markup only in `content`. — _origin: [RSS.app feed output guide](https://help.rss.app/en/articles/10769849-guide-to-feed-output); upstream [ktn#11](https://github.com/leafac/kill-the-newsletter/issues/11) (subject placed as link)_
|
||||||
|
|
||||||
## Per-feed favicon — design notes
|
## 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.
|
Breakdown of the _"Per-feed favicon from the last sender's domain"_ item above (the parent is `P2·M`; these sub-tasks are each ~`S`). 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] `P2·S` **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] `P2·S` **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] `P2·S` **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] `P2·S` **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] `P2·S` **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.
|
- [x] `P2·S` **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.
|
||||||
|
|
||||||
|
## Operability, versioning & ecosystem (2026-05-24)
|
||||||
|
|
||||||
|
Self-host operational quality-of-life: knowing which version you run, when to update, and how many people run KTN.
|
||||||
|
|
||||||
|
- [x] `P3·S` **Display the running version** **[table-stakes, easy]** — surface the deployed app version (from `package.json` `version`, currently `0.2.1`) somewhere visible: the admin UI footer and/or the public status page (`src/routes/home.tsx`), and ideally the `/health` JSON. Bundle the version at build time (inline the `package.json` version into the Worker, since there's no filesystem at runtime) and render it. Foundation for the update-notification item below. — **Shipped:** `package.json` version is inlined at bundle time via `src/config/version.ts` (`import pkg from "../../package.json"`, `resolveJsonModule`), exposed as `APP_VERSION`; rendered in the shared admin/status footer (`src/routes/admin/ui.tsx` Layout, so both the status page and admin show it), added to the `/health` JSON, and to the canonical monitoring endpoint `/api/v1/stats` (`StatsResponse.version`, public). — _origin: internal_
|
||||||
|
|
||||||
|
- [ ] `P3·M` **Notify when an update is available** **[differentiating for self-hosters]** — compare the running version against the latest GitHub Release tag and show a discreet "update available → vX.Y.Z" banner in the admin UI when behind. Fetch `https://api.github.com/repos/<owner>/<repo>/releases/latest` (cache aggressively — Cache API / KV with a long TTL — to respect GitHub rate limits and avoid a call per page load), compare semver against the bundled version. Depends on the "display version" item. Keep it opt-out-able (it makes one outbound call). — _origin: internal_
|
||||||
|
|
||||||
|
- [ ] `P3·L` **Public instances directory and/or instance counter (opt-in telemetry)** **[differentiating, ecosystem]** — let a self-hosted instance optionally announce itself to a central registry so we can show a count of live instances (and, if the operator opts in to being listed, a public directory of instances). Each instance periodically pings a central endpoint (on the existing cron) with minimal, **opt-in** data (e.g. an anonymous instance id + version; a public listing would additionally need a name/URL the operator explicitly provides). ⚠ Privacy-first: **off by default**, clearly documented, no PII/feed data ever sent; respect "count me but don't list me". Needs a central collector (a separate tiny Worker + KV/DO) plus an `INSTANCE_TELEMETRY`/`INSTANCE_DIRECTORY` opt-in env on the client side, fired from `index.ts`'s `scheduled` handler. — _origin: internal_
|
||||||
|
|
||||||
|
## Epic: Pluggable runtime, storage & ingestion (off-Cloudflare support)
|
||||||
|
|
||||||
|
`P2·XL` **Run KTN off Cloudflare from one codebase, adapter-selected by config.** Reference non-CF target: **Clever Cloud** (container + Cellar S3 + a KV/SQL add-on) with **Sweego** inbound for email. — _origin: internal (broader audience / reduced lock-in)_
|
||||||
|
|
||||||
|
**Context / motivation.** KTN is Cloudflare-native: Workers runtime, KV + R2 bindings, Email Workers, cron triggers. The v0.2.0 DDD refactor already introduced the seams that make portability tractable — KV access is behind repository adapters (`FeedRepository`, `IconRepository`, `WebSubSubscriptionRepository`, `CountersRepository`), ingestion is transport-agnostic (`processEmail` is decoupled from the CF email handler, and a webhook path `/api/inbound` already exists), HTTP is Hono (runtime-agnostic), and background work is abstracted behind `BackgroundScheduler`. This epic turns those seams into selectable adapters so KTN can run on a plain Node/container host with non-CF storage and email ingestion.
|
||||||
|
|
||||||
|
**Goal / outcome.** KTN runs on two reference profiles from one codebase:
|
||||||
|
|
||||||
|
- **A — CF-native (today):** Workers + KV + R2 + Cloudflare Email Routing.
|
||||||
|
- **B — Clever+Sweego:** Node container + Cellar (S3 blob) + KV-store add-on + Sweego inbound webhook + Node scheduler.
|
||||||
|
|
||||||
|
Adapter chosen by config (env), no code change. Same test suite green on both.
|
||||||
|
|
||||||
|
**Coupling points → adapters.**
|
||||||
|
|
||||||
|
| Area | CF-native (today) | New adapter (target B) |
|
||||||
|
| ------------------ | ------------------------------------------------------ | -------------------------------------------------------------- |
|
||||||
|
| Runtime/entrypoint | `export default { fetch, email, scheduled }` | Node entrypoint (`@hono/node-server`) + Dockerfile |
|
||||||
|
| HTTP | Hono (portable) | Hono (no change; abstract CF-only globals) |
|
||||||
|
| KV store | `KVNamespace` binding | SQL (Postgres/SQLite) or Redis (Materia KV) adapter |
|
||||||
|
| Blob/attachments | `R2Bucket` binding | S3-compatible (Cellar) via aws4fetch/S3 client |
|
||||||
|
| Email ingestion | CF Email Worker (`ForwardableEmailMessage`) | Sweego inbound webhook → `/api/inbound` |
|
||||||
|
| Cron cleanup | CF cron trigger | Node scheduler (node-cron) or external trigger |
|
||||||
|
| Background | `ctx.waitUntil` (already behind `BackgroundScheduler`) | run-and-await Node impl |
|
||||||
|
| Config/DI | CF bindings on `Env` | driver-selection layer (`*_DRIVER` envs) wiring repos→backends |
|
||||||
|
|
||||||
|
**Sub-tasks (deliverable independently).**
|
||||||
|
|
||||||
|
- [ ] `P2·M` **Storage driver abstraction + config layer** — formalize the repository interfaces already implied by `FeedRepository` et al.; add a DI/config layer selecting backends from env. Foundation; no behavior change on CF. — _origin: internal_
|
||||||
|
- [ ] `P2·M` **Blob adapter: S3-compatible (Cellar)** — put attachments behind a `BlobStore` interface; CF R2 + S3 (aws4fetch, works on Workers and Node). Lowest risk, immediately reusable. — _origin: internal_
|
||||||
|
- [ ] `P2·L` **KV-store adapter for self-host** — implement the key schema over SQL (recommended: Postgres/SQLite for list-by-prefix semantics) and/or Redis. ⚠ If targeting Materia KV, confirm KTN never relies on `RENAME` (Materia lacks it — see consumer's ADR-0011); audit the single key schema. — _origin: internal_
|
||||||
|
- [ ] `P2·L` **Node runtime entrypoint + container** — `@hono/node-server`, Dockerfile, health endpoint; abstract CF-only globals (`caches`, reliance on `CF-Connecting-IP` in proxy-auth → generalize to `X-Forwarded-For`/trusted-proxy config). — _origin: internal_
|
||||||
|
- [ ] `P2·L` **Ingestion transport abstraction + Sweego adapter** — generalize `/api/inbound` to provider-agnostic: pluggable payload parser (Sweego JSON → `ProcessEmailInput`, mirroring `parseForwardEmailPayload`) + pluggable webhook auth (HMAC signature / shared secret / IP allowlist). Document that `message.forward()` fallback is CF-Email-Worker-only; on webhook transports, unmatched-mail handling is the provider's concern (Sweego catch-all is isolated to the inbound domain, so the fallback hack isn't needed). — _origin: internal_
|
||||||
|
- [ ] `P2·M` **Scheduler adapter** — make `feed-cleanup` runnable via a Node scheduler or an authenticated `/internal/cron` endpoint for external triggers. — _origin: internal_
|
||||||
|
- [ ] `P2·M` **CI matrix + docs** — build/test both targets; INSTALL.md Clever+Sweego profile; deployment guide. — _origin: internal_
|
||||||
|
|
||||||
|
**Open questions (resolve before the Sweego adapter sub-task).**
|
||||||
|
|
||||||
|
- Sweego inbound: webhook auth mechanism (HMAC? signed header? IP list?), JSON payload schema, and attachment delivery (inline base64 vs URLs vs multipart) — drives the parser + how attachments stream into the blob store.
|
||||||
|
- Clever KV backend choice: Materia KV (Redis, no `RENAME`) vs Postgres add-on — decide from the key-op audit in the KV-store sub-task.
|
||||||
|
|
||||||
|
**Out of scope.**
|
||||||
|
|
||||||
|
- Running KTN's own SMTP/MTA server (inbound stays delegated: CF Email Routing, Sweego, or ForwardEmail). No port-25 listener.
|
||||||
|
- Multi-tenant / multi-domain admin.
|
||||||
|
|
||||||
|
**Acceptance criteria.**
|
||||||
|
|
||||||
|
- One codebase deploys to both profiles via config only.
|
||||||
|
- Full vitest suite green on both runtimes.
|
||||||
|
- Documented end-to-end Clever+Sweego deploy: a newsletter to `noun.noun.NN@<inbound-domain>` lands in a feed; attachments served from Cellar; cleanup cron runs.
|
||||||
|
- No regression on the CF-native profile.
|
||||||
|
|||||||
@@ -778,6 +778,14 @@
|
|||||||
<p>Your emails and feeds live exclusively in your own Cloudflare account. No shared infrastructure, no data mining.</p>
|
<p>Your emails and feeds live exclusively in your own Cloudflare account. No shared infrastructure, no data mining.</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"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Separate Address & Feed URL</h3>
|
||||||
|
<p>Your subscribe address and your feed's reading URL are independent, unguessable ids. Share a feed without leaking the inbox that feeds it — and an address a newsletter harvests can never be turned into your feed.</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"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||||
@@ -834,6 +842,14 @@
|
|||||||
<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>
|
<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>
|
||||||
|
|
||||||
|
<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="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Catch-All Fallback</h3>
|
||||||
|
<p>Point your whole domain at kill-the-news: anything that isn't a feed is forwarded to a fallback address instead of dropped, so your personal mail still gets through.</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>
|
||||||
@@ -850,6 +866,46 @@
|
|||||||
<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>
|
<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 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="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>RSS, Atom & JSON Feed</h3>
|
||||||
|
<p>Every feed is served in all three formats — RSS 2.0, Atom, and JSON Feed — so it just works in NetNewsWire, Reeder, Feedly, NewsBlur and any other reader. Conditional requests (ETag / 304) keep polling cheap.</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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>One-Click OPML Export</h3>
|
||||||
|
<p>Export all your feeds as an OPML file and bulk-import them into any reader in one shot — easy onboarding, and no lock-in if you ever want to move.</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="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 1.27h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.37a16 16 0 0 0 5.72 5.72l1.17-.94a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/><path d="M14.05 2a9 9 0 0 1 8 7.94"/><path d="M14.05 6A5 5 0 0 1 18 10"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Never Lose a Confirmation Link</h3>
|
||||||
|
<p>kill-the-news detects "confirm your subscription" emails at ingestion and surfaces the link prominently in the admin — unlike kill-the-newsletter, where the confirm email lands buried in your feed reader and is easily missed.</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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Find the Source Feed</h3>
|
||||||
|
<p>If a newsletter already publishes RSS, Atom, or JSON Feed, kill-the-news spots it and points you to the original — subscribe at the source directly when you prefer.</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="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.077.877.528 1.073 1.01a2.5 2.5 0 1 0 3.259-3.259c-.482-.196-.933-.558-1.01-1.073-.05-.336.062-.676.303-.917l1.525-1.525A2.402 2.402 0 0 1 12 1.998c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Native FreshRSS Support</h3>
|
||||||
|
<p>Manage your kill-the-news feeds without leaving <a href="https://freshrss.org" target="_blank" rel="noopener" style="color:var(--accent)">FreshRSS</a>, thanks to the <a href="https://github.com/juherr/xExtension-KillTheNews" target="_blank" rel="noopener" style="color:var(--accent)">xExtension-KillTheNews</a> extension.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
|||||||
|
# Detect a newsletter's native Atom/RSS/JSON feed — design
|
||||||
|
|
||||||
|
_Date: 2026-05-25 · Backlog item: TODO.md "Detect a newsletter's native Atom/RSS feed" (P2·S)_
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
When an incoming newsletter email's HTML advertises its own syndication feed via
|
||||||
|
`<link rel="alternate" type="…">`, detect it and surface it to the admin:
|
||||||
|
"this newsletter already publishes a feed — you can subscribe to it directly."
|
||||||
|
|
||||||
|
A genuine differentiator: it is the top item on upstream kill-the-newsletter's
|
||||||
|
own TODO and is not built there. Per the user, detect **Atom, RSS, and JSON Feed**.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Reuse the existing, proven pipeline — identical to the **confirmation-detection**
|
||||||
|
feature and the **per-sender unsubscribe** storage:
|
||||||
|
|
||||||
|
infra parses the HTML → a **pure domain detector** decides which links count →
|
||||||
|
the result rides into the aggregate via `IngestOptions` (like `unsub`) → the
|
||||||
|
aggregate stores it per-sender → admin surfaces it (detail + list badge +
|
||||||
|
dashboard pill + dismiss) → exposed read-only on the REST API.
|
||||||
|
|
||||||
|
Alternative considered and rejected: an ad-hoc detector living in infrastructure.
|
||||||
|
The "which MIME types are a feed" rule is business knowledge, so it belongs in
|
||||||
|
`domain/`, mirroring `domain/confirmation.ts`.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// New value shape
|
||||||
|
type NativeFeed = { url: string; type: "rss" | "atom" | "json" };
|
||||||
|
|
||||||
|
// FeedMetadata (src/types/index.ts) — additive, like `unsubscribe` / `pendingConfirmation`
|
||||||
|
nativeFeeds?: Record<string, NativeFeed[]>; // key = senderKey (same scheme as `unsubscribe`)
|
||||||
|
nativeFeedDismissed?: boolean; // dismiss: hides pill + badge, keeps the URLs
|
||||||
|
|
||||||
|
// FeedListItem (src/types/index.ts) — projected flag for the dashboard, like pendingConfirmation
|
||||||
|
hasNativeFeed?: boolean;
|
||||||
|
```
|
||||||
|
|
||||||
|
Update semantics (chosen by the user: "accumulation, but latest-per-sender"):
|
||||||
|
|
||||||
|
- Storage is **per sender**, keyed by the same `senderKey` used for `unsubscribe`
|
||||||
|
(`input.senders[0] || iconDomain || input.from`).
|
||||||
|
- **Latest non-empty wins per sender**: the most recent email from a given sender
|
||||||
|
that declares feeds overwrites _that sender's_ list; other senders are preserved.
|
||||||
|
Mirrors how `ingest` updates `unsubscribe` only when an unsubscribe URL is present.
|
||||||
|
- The aggregate exposes `nativeFeeds(): NativeFeed[]` = the **union across senders,
|
||||||
|
deduped by URL** (returns a copy).
|
||||||
|
- Projected flag `hasNativeFeed = nativeFeeds().length > 0 && !nativeFeedDismissed`.
|
||||||
|
|
||||||
|
Smart re-notify (avoid nagging on every email):
|
||||||
|
|
||||||
|
- On ingest, if a **previously-unseen URL** appears (not in the current union),
|
||||||
|
clear `nativeFeedDismissed` (re-raise the notice).
|
||||||
|
- If ingestion only re-discovers already-known URLs, leave `nativeFeedDismissed`
|
||||||
|
untouched, so a dismiss sticks until a genuinely new native feed shows up.
|
||||||
|
|
||||||
|
## Detection
|
||||||
|
|
||||||
|
**Infra — `src/infrastructure/html-processor.ts`**: new
|
||||||
|
`extractFeedLinks(content): { href: string; type: string }[]`.
|
||||||
|
|
||||||
|
- Parse `<link>` elements whose `rel` token-list contains `alternate` and that
|
||||||
|
carry a `type` attribute (`link[rel~="alternate"][type]`).
|
||||||
|
- Return the raw `href` + `type` tuples; absolutize a relative `href` best-effort
|
||||||
|
via the existing `toAbsolute` helper; http(s) only (drop others).
|
||||||
|
- Plain-text bodies have no `<link>` → returns `[]`.
|
||||||
|
|
||||||
|
**Domain — `src/domain/native-feed.ts`** (pure, no DOM/IO, mirrors `confirmation.ts`):
|
||||||
|
`detectNativeFeeds(links): NativeFeed[]`.
|
||||||
|
|
||||||
|
- Owns the recognized MIME → kind table (strict, the three canonical types only):
|
||||||
|
- `application/atom+xml` → `"atom"`
|
||||||
|
- `application/rss+xml` → `"rss"`
|
||||||
|
- `application/feed+json` → `"json"`
|
||||||
|
- Ignores any other type (no `application/json` — too broad, would capture non-feeds).
|
||||||
|
- Dedupes by URL; preserves first-seen kind for a URL.
|
||||||
|
|
||||||
|
## Ingestion wiring
|
||||||
|
|
||||||
|
**`src/application/email-processor.ts`**: alongside the existing confirmation +
|
||||||
|
unsubscribe extraction, call `extractFeedLinks(input.content)` →
|
||||||
|
`detectNativeFeeds(...)`. When non-empty, pass into `feed.ingest` via
|
||||||
|
`IngestOptions`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
nativeFeeds?: { senderKey: string; feeds: NativeFeed[] };
|
||||||
|
```
|
||||||
|
|
||||||
|
`senderKey` is the same value already computed for `unsub`.
|
||||||
|
|
||||||
|
**`src/domain/feed.aggregate.ts`**:
|
||||||
|
|
||||||
|
- `ingest`: when `opts.nativeFeeds` is present, set
|
||||||
|
`_metadata.nativeFeeds[senderKey] = feeds`; if any feed URL is new vs the
|
||||||
|
pre-update union, set `_metadata.nativeFeedDismissed = false`.
|
||||||
|
- Getter `nativeFeeds(): NativeFeed[]` — union deduped by URL (copy).
|
||||||
|
- Getter `hasNativeFeed(): boolean` — `nativeFeeds().length > 0 && !dismissed`.
|
||||||
|
- `dismissNativeFeed(): void` — sets `nativeFeedDismissed = true` (lower-only,
|
||||||
|
mirrors `dismissConfirmation`).
|
||||||
|
- `removeEmails` does **not** touch native feeds (the data is per-sender, not
|
||||||
|
per-email; deleting emails should not drop a discovered native feed).
|
||||||
|
|
||||||
|
**`src/infrastructure/feed-mapper.ts`**: `toListItemDTO` gains a `hasNativeFeed`
|
||||||
|
parameter (like `pendingConfirmation`), projected into `FeedListItem`. The
|
||||||
|
repository passes `feed.hasNativeFeed()` when saving. `nativeFeeds` /
|
||||||
|
`nativeFeedDismissed` persist as part of `FeedMetadata` (stored directly in KV —
|
||||||
|
additive, no mapper change for the metadata blob itself).
|
||||||
|
|
||||||
|
## REST API
|
||||||
|
|
||||||
|
**`src/routes/api/schemas.ts`** — `FeedSchema` gains a read-only field:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
nativeFeeds: z.array(z.object({
|
||||||
|
url: z.string(),
|
||||||
|
type: z.enum(["rss", "atom", "json"]),
|
||||||
|
})),
|
||||||
|
```
|
||||||
|
|
||||||
|
Populated from `feed.nativeFeeds()` in the feed-read handler so an API client can
|
||||||
|
choose which native feed to subscribe to. Read-only — not accepted on
|
||||||
|
`FeedCreate`/`FeedUpdate`.
|
||||||
|
|
||||||
|
## Admin UI
|
||||||
|
|
||||||
|
Mirror the confirmation surfaces (detail + badge + pill + dismiss).
|
||||||
|
|
||||||
|
- **Detail (per-feed view, `src/routes/admin/emails.tsx`)**: next to the existing
|
||||||
|
`FeedFormats` "Subscribe" block (the **KTN feeds**), render a second group
|
||||||
|
**"Native feeds"** when `nativeFeeds()` is non-empty. Each native feed is a
|
||||||
|
copyable chip (type label RSS/Atom/JSON + copy + open-in-new-tab), reusing the
|
||||||
|
existing copyable/chip styling. Net result: KTN feeds on one side, native feeds
|
||||||
|
on the other, both copy-pasteable into a reader. Include a "dismiss" control
|
||||||
|
(POST to the dismiss route) to clear the dashboard/list notice.
|
||||||
|
- **List badge (`src/routes/admin.tsx` feed row)**: a discreet badge when
|
||||||
|
`hasNativeFeed`.
|
||||||
|
- **Dashboard pill**: `pill-native` (styled like `pill-confirmation`) on the
|
||||||
|
dashboard feed list when `hasNativeFeed`.
|
||||||
|
- **Dismiss route**: `POST /admin/feeds/:feedId/native-feed/dismiss` → load
|
||||||
|
aggregate → `feed.dismissNativeFeed()` → save → JSON ok. Client script wired
|
||||||
|
like the existing `confirmation-dismiss` (in `src/scripts/client/`).
|
||||||
|
- **Styles**: add `.native-feeds` group + `.pill-native` + badge rules in
|
||||||
|
`src/styles/components.css`, matching the format-chip / confirmation styling.
|
||||||
|
|
||||||
|
## Out of scope (v1)
|
||||||
|
|
||||||
|
- No change to the **public XML/JSON feed output** (user decision: native feeds
|
||||||
|
live in admin + REST, not in the rendered feeds).
|
||||||
|
- No anchor-text heuristics ("Subscribe via RSS" links) — `<link rel=alternate>`
|
||||||
|
only, to keep false positives near zero.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `src/domain/native-feed.test.ts` — MIME mapping, dedupe, ignores unknown types.
|
||||||
|
- `src/infrastructure/html-processor.test.ts` — `extractFeedLinks`: rel/type
|
||||||
|
parsing, relative-href absolutization, http(s)-only, plain-text → `[]`.
|
||||||
|
- `src/domain/feed.aggregate.test.ts` — ingest per-sender latest-wins, union
|
||||||
|
getter, re-notify on new URL, dismiss lower-only, removeEmails leaves it intact.
|
||||||
|
- `src/application/email-processor.test.ts` — end-to-end: an email with a
|
||||||
|
`<link rel=alternate>` populates `nativeFeeds`; one without leaves it alone.
|
||||||
|
- `src/infrastructure/feed-mapper.test.ts` / repository — `hasNativeFeed`
|
||||||
|
projection into `feeds:list`.
|
||||||
|
- `src/routes/admin.test.ts` — detail "native-feeds" group, list badge, dashboard
|
||||||
|
`pill-native`, dismiss route clears the flag.
|
||||||
|
- REST API test — `FeedSchema.nativeFeeds` present in the feed-read response.
|
||||||
|
|
||||||
|
End green: `npx tsc --noEmit`, `npm test`, `npm run build`.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- `README.md` / `INSTALL.md` — mention native-feed detection.
|
||||||
|
- `docs/index.html` (marketing landing) — add a feature card (it's a
|
||||||
|
differentiator we ship before upstream).
|
||||||
|
- `CLAUDE.md` — add `domain/native-feed.ts` to the source layout; note the new
|
||||||
|
`FeedMetadata.nativeFeeds` / `nativeFeedDismissed` fields.
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Subscription confirmation surfacing — design
|
||||||
|
|
||||||
|
_Date: 2026-05-25 · Origin: TODO.md `P1·M` "Subscription confirmation handling" (upstream issues #5, #23, #57, #73, #89, #95, #97)_
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Newsletters require a "click to confirm your subscription" step. The confirmation
|
||||||
|
email lands in the feed like any other item, so the user has to hunt the link
|
||||||
|
inside a feed reader — the single most recurring upstream request. Until they
|
||||||
|
confirm, the feed stays empty and the tool looks broken.
|
||||||
|
|
||||||
|
## Goal (v1 scope)
|
||||||
|
|
||||||
|
Detect confirmation emails at ingestion, **mark** them, and **surface the
|
||||||
|
confirmation link prominently** in the admin so the user can click it. No outbound
|
||||||
|
requests — the worker never follows the link in v1.
|
||||||
|
|
||||||
|
Explicitly deferred to a later batch (noted in TODO): a server-configured
|
||||||
|
on-detection action (`none` / `autoclick` / `forward` to the fallback box).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No auto-clicking / server-side following of confirmation links (SSRF surface).
|
||||||
|
- No per-feed action configuration.
|
||||||
|
- No detection of whether confirmation actually succeeded (impossible without
|
||||||
|
autoclick) — "dismiss" just stops the reminder.
|
||||||
|
|
||||||
|
## Architecture overview
|
||||||
|
|
||||||
|
Detection is a **pure domain service** fed by infra-extracted links/text; the
|
||||||
|
result is persisted on the email's metadata at ingestion and a feed-level flag is
|
||||||
|
projected into `feeds:list` so the dashboard stays at one KV read.
|
||||||
|
|
||||||
|
```
|
||||||
|
ingestion (storeEmail)
|
||||||
|
├─ infra: htmlToText(content) + extractLinks(content)
|
||||||
|
├─ domain: detectConfirmation({subject, text, links}) → {score, links[]} | null
|
||||||
|
├─ if detected → EmailMetadata.confirmation = { links }
|
||||||
|
└─ Feed.ingest() raises FeedMetadata.pendingConfirmation = true
|
||||||
|
→ FeedRepository.saveMetadata projects pendingConfirmation into feeds:list
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Detection
|
||||||
|
|
||||||
|
### `src/domain/confirmation.ts` (new, pure — no DOM)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
detectConfirmation(input: {
|
||||||
|
subject: string;
|
||||||
|
text: string; // plain-text rendition of the body
|
||||||
|
links: { href: string; text: string }[];
|
||||||
|
}): { score: number; links: string[] } | null
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Multilingual keyword lists** (FR/EN/DE/ES and extensible) for subject + body:
|
||||||
|
`confirm`, `verify`, `activate`, `subscribe`, `confirmer`, `valider`,
|
||||||
|
`activer`, `bestätigen`, `confirmar`, `verificar`, … (case/diacritic-insensitive
|
||||||
|
matching).
|
||||||
|
- **Link scoring** by anchor text + URL path/query signals: `/confirm`, `/verify`,
|
||||||
|
`/activate`, `/subscribe`, `/opt-in`, `token=`, `confirm=`, `?c=`, etc.
|
||||||
|
- **Combined score** = subject/body keyword signal + best link signal. Above a
|
||||||
|
tuned `THRESHOLD` → return the ranked candidate links (top 3). Below → `null`.
|
||||||
|
- Only `http(s):` links are ever considered/returned (no `javascript:`/`data:`/
|
||||||
|
`mailto:`).
|
||||||
|
- This module owns the business knowledge (keyword vocab, weights, threshold);
|
||||||
|
it is unit-tested in isolation.
|
||||||
|
|
||||||
|
### `src/infrastructure/html-processor.ts` — `extractLinks`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
extractLinks(content: string): { href: string; text: string }[]
|
||||||
|
```
|
||||||
|
|
||||||
|
- HTML: linkedom parse, collect `<a href>` + anchor text.
|
||||||
|
- Plain text: regex URL fallback (href = url, text = url).
|
||||||
|
- Infra owns DOM parsing; the domain receives plain data.
|
||||||
|
|
||||||
|
### `src/application/email-processor.ts` — wire-in
|
||||||
|
|
||||||
|
In `storeEmail`, before building `newEntry`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const text = htmlToText(input.content);
|
||||||
|
const links = extractLinks(input.content);
|
||||||
|
const confirmation = detectConfirmation({
|
||||||
|
subject: input.subject,
|
||||||
|
text,
|
||||||
|
links,
|
||||||
|
});
|
||||||
|
// → EmailMetadata.confirmation = confirmation ? { links: confirmation.links } : undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
Computed once at reception. Dedup/ingest flow otherwise unchanged.
|
||||||
|
|
||||||
|
## 2. Data model (additive)
|
||||||
|
|
||||||
|
### `EmailMetadata` (`src/types/index.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
confirmation?: { links: string[] }; // present ⇒ detected; links = ranked top-3
|
||||||
|
```
|
||||||
|
|
||||||
|
Presence powers the list badge and the detail section. Additive → pre-feature
|
||||||
|
emails have nothing (no retroactive false positives).
|
||||||
|
|
||||||
|
### `FeedMetadata` (`src/types/index.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
pendingConfirmation?: boolean; // ≥1 unactioned confirmation email present
|
||||||
|
```
|
||||||
|
|
||||||
|
### `FeedListItem` (`src/types/index.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
pendingConfirmation?: boolean; // projected from FeedMetadata for the dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aggregate `Feed` (`src/domain/feed.aggregate.ts`)
|
||||||
|
|
||||||
|
- `ingest()` sets `pendingConfirmation = true` when the new `EmailMetadata` carries
|
||||||
|
`confirmation`.
|
||||||
|
- `removeEmails()` recomputes the flag (false when no confirmation email remains).
|
||||||
|
- `dismissConfirmation()` sets it to false.
|
||||||
|
- read accessor `pendingConfirmation`.
|
||||||
|
|
||||||
|
### Persistence (`src/infrastructure/feed-repository.ts` + `feed-mapper.ts`)
|
||||||
|
|
||||||
|
- `FeedMetadata` round-trips `pendingConfirmation`.
|
||||||
|
- **Sync invariant extended**: `saveMetadata` projects `pendingConfirmation` into
|
||||||
|
the `feeds:list` item (today only `save`/`saveConfig` upsert the list). This is
|
||||||
|
the one metadata-derived field the list carries; it keeps the dashboard at a
|
||||||
|
single KV read instead of N per-feed metadata reads.
|
||||||
|
|
||||||
|
## 3. Admin UI
|
||||||
|
|
||||||
|
### a) Detail view — dedicated section (`routes/admin/emails.tsx`, `GET /emails/:emailKey`)
|
||||||
|
|
||||||
|
Above the Rendered/Raw toggle, shown when `email.confirmation`:
|
||||||
|
|
||||||
|
- Heading "Confirm your subscription".
|
||||||
|
- Best-scored link as a **primary button**; remaining candidates as secondary
|
||||||
|
links. URLs shown in clear text (transparency). `target="_blank" rel="noopener
|
||||||
|
noreferrer"`.
|
||||||
|
|
||||||
|
### b) Email list badge (`routes/admin/emails.tsx`, `GET /feeds/:feedId/emails`)
|
||||||
|
|
||||||
|
A "Confirmation" badge in the subject cell of rows where `email.confirmation`
|
||||||
|
exists, styled like the existing attachment indicator.
|
||||||
|
|
||||||
|
### c) Dashboard indicator (`routes/admin.tsx`, `GET /`)
|
||||||
|
|
||||||
|
A "Confirmation pending" pill on feeds whose `FeedListItem.pendingConfirmation` is
|
||||||
|
true (list + table views), read with zero extra KV reads. Links to the feed's
|
||||||
|
emails page.
|
||||||
|
|
||||||
|
### d) Feed emails-page banner (`routes/admin/emails.tsx`)
|
||||||
|
|
||||||
|
When `feedMetadata.pendingConfirmation`: a top banner "A confirmation email was
|
||||||
|
detected" linking to the latest confirmation email, plus a "Mark as confirmed"
|
||||||
|
button (dismiss).
|
||||||
|
|
||||||
|
### Post-creation redirect
|
||||||
|
|
||||||
|
`POST /admin/feeds/create` redirects to `/admin/feeds/:feedId/emails` (not the
|
||||||
|
dashboard) so the user lands where the banner appears once the (async) confirmation
|
||||||
|
email arrives.
|
||||||
|
|
||||||
|
## 4. Dismiss action
|
||||||
|
|
||||||
|
`POST /admin/feeds/:feedId/confirmation/dismiss`:
|
||||||
|
|
||||||
|
- load aggregate → `feed.dismissConfirmation()` → `repo.saveMetadata(feed)`
|
||||||
|
(reprojects `pendingConfirmation:false` into `feeds:list`).
|
||||||
|
- JSON response for the banner's fetch (mirrors the existing sender-filter
|
||||||
|
pattern); redirect fallback for no-JS.
|
||||||
|
- Protected by the existing admin auth + CSRF middleware.
|
||||||
|
|
||||||
|
"Dismiss" = "stop reminding me" (no real confirmation tracking without autoclick).
|
||||||
|
Deleting the confirmation email(s) also clears the flag via `removeEmails`.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- v1 performs **no outbound request** → no SSRF surface.
|
||||||
|
- Candidate `href`s filtered to `http(s):` only; never `javascript:`/`data:`.
|
||||||
|
- Output escaped via normal hono/jsx rendering.
|
||||||
|
|
||||||
|
## Testing (TDD)
|
||||||
|
|
||||||
|
- `confirmation.test.ts` (domain): multilingual scoring, threshold, link ranking,
|
||||||
|
negative cases (normal newsletter doesn't trigger), plain-text body.
|
||||||
|
- `html-processor.test.ts`: `extractLinks` for HTML and plain text.
|
||||||
|
- `email-processor.test.ts`: confirmation email → `EmailMetadata.confirmation`
|
||||||
|
populated + `pendingConfirmation` raised; dedup/ingest otherwise unchanged.
|
||||||
|
- `admin.test.ts` / emails tests: list badge, detail section, dashboard pill,
|
||||||
|
banner, dismiss route (clears flag + reprojects into list).
|
||||||
|
|
||||||
|
Close green: `npx tsc --noEmit`, `npm test`, `npm run build`.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
- `README.md` + `INSTALL.md`: "Subscription confirmation surfacing" capability.
|
||||||
|
- `docs/index.html` (landing): feature card — a genuine differentiator vs
|
||||||
|
kill-the-newsletter.
|
||||||
|
- `TODO.md`: check off `P1·M` "Subscription confirmation handling"; add a new
|
||||||
|
`P2·M` "Confirmation on-detect action (none/autoclick/forward)" item capturing
|
||||||
|
the deferred server options + SSRF/fallback notes.
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "kill-the-news",
|
"name": "kill-the-news",
|
||||||
"version": "0.1.0",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "kill-the-news",
|
"name": "kill-the-news",
|
||||||
"version": "0.1.0",
|
"version": "0.3.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/zod-openapi": "^1.4.0",
|
"@hono/zod-openapi": "^1.4.0",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kill-the-news",
|
"name": "kill-the-news",
|
||||||
"version": "0.1.0",
|
"version": "0.3.1",
|
||||||
"description": "Convert email newsletters into private RSS feeds using Cloudflare Workers",
|
"description": "Convert email newsletters into private RSS feeds using Cloudflare Workers",
|
||||||
"main": "dist/worker.js",
|
"main": "dist/worker.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"typecheck": "tsc --noEmit && npm run typecheck:client",
|
"typecheck": "tsc --noEmit && npm run typecheck:client",
|
||||||
"typecheck:client": "tsc -p src/scripts/client/tsconfig.json --noEmit",
|
"typecheck:client": "tsc -p src/scripts/client/tsconfig.json --noEmit",
|
||||||
|
"release": "bash scripts/release.sh",
|
||||||
"prepare": "husky && npm run build:client"
|
"prepare": "husky && npm run build:client"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
|||||||
Executable
+151
@@ -0,0 +1,151 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Cut a release. Usage:
|
||||||
|
#
|
||||||
|
# npm run release X.Y.Z [NEXT_DEV_BASE]
|
||||||
|
#
|
||||||
|
# X.Y.Z the version to release (must equal main's current X.Y.Z-develop base)
|
||||||
|
# NEXT_DEV_BASE optional base to open next (defaults to next minor, e.g. 0.4.0 -> 0.5.0)
|
||||||
|
#
|
||||||
|
# It guards, then in one shot:
|
||||||
|
# 1. promotes CHANGELOG "## [Unreleased]" -> "## [X.Y.Z] - <date>"
|
||||||
|
# 2. sets package.json to the bare X.Y.Z and commits the release commit
|
||||||
|
# 3. tags vX.Y.Z on that commit
|
||||||
|
# 4. opens the next "-develop" cycle (package.json + fresh Unreleased) and commits
|
||||||
|
# 5. pushes main + the tag (after an explicit confirmation) -> triggers the Release workflow
|
||||||
|
#
|
||||||
|
# The tag points at a commit whose package.json reads exactly X.Y.Z, so the
|
||||||
|
# published bundle and the git history agree on the version. CI verifies the
|
||||||
|
# match and publishes the promoted CHANGELOG section as the release notes.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "release: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
semver_re='^[0-9]+\.[0-9]+\.[0-9]+$'
|
||||||
|
|
||||||
|
VERSION="${1:-}"
|
||||||
|
[ -n "$VERSION" ] || die "missing version. Usage: npm run release X.Y.Z [NEXT_DEV_BASE]"
|
||||||
|
[[ "$VERSION" =~ $semver_re ]] || die "version '$VERSION' is not X.Y.Z"
|
||||||
|
|
||||||
|
# Default next dev base: bump the minor, reset patch.
|
||||||
|
if [ -n "${2:-}" ]; then
|
||||||
|
NEXT_BASE="$2"
|
||||||
|
[[ "$NEXT_BASE" =~ $semver_re ]] || die "next dev base '$NEXT_BASE' is not X.Y.Z"
|
||||||
|
else
|
||||||
|
IFS='.' read -r MA MI _PA <<<"$VERSION"
|
||||||
|
NEXT_BASE="${MA}.$((MI + 1)).0"
|
||||||
|
fi
|
||||||
|
NEXT_DEV="${NEXT_BASE}-develop"
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
# --- Guards ----------------------------------------------------------------
|
||||||
|
|
||||||
|
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
[ "$BRANCH" = "main" ] || die "must be on 'main' (currently on '$BRANCH')"
|
||||||
|
|
||||||
|
[ -z "$(git status --porcelain)" ] || die "working tree is not clean — commit or stash first"
|
||||||
|
|
||||||
|
git fetch --quiet origin main || die "could not fetch origin/main"
|
||||||
|
LOCAL="$(git rev-parse @)"
|
||||||
|
REMOTE="$(git rev-parse '@{u}')"
|
||||||
|
[ "$LOCAL" = "$REMOTE" ] || die "local main is not in sync with origin/main — pull/push first"
|
||||||
|
|
||||||
|
PKG_BASE="$(node -p 'require("./package.json").version.split("-")[0]')"
|
||||||
|
[ "$PKG_BASE" = "$VERSION" ] || die "package.json base is $PKG_BASE, expected $VERSION — bump main to ${VERSION}-develop first (or release $PKG_BASE)"
|
||||||
|
|
||||||
|
git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null && die "tag v$VERSION already exists"
|
||||||
|
|
||||||
|
[ -f CHANGELOG.md ] || die "CHANGELOG.md not found"
|
||||||
|
# The Unreleased section must carry content — an empty changelog ships empty notes.
|
||||||
|
UNRELEASED_BODY="$(awk '
|
||||||
|
/^## \[Unreleased\]/ {grab=1; next}
|
||||||
|
/^## / && grab {exit}
|
||||||
|
grab {print}
|
||||||
|
' CHANGELOG.md | grep -v '^[[:space:]]*$' || true)"
|
||||||
|
[ -n "$UNRELEASED_BODY" ] || die "CHANGELOG '## [Unreleased]' is empty — write the release notes there first"
|
||||||
|
|
||||||
|
# --- Plan ------------------------------------------------------------------
|
||||||
|
|
||||||
|
DATE="$(date +%Y-%m-%d)"
|
||||||
|
echo "Release plan:"
|
||||||
|
echo " version : $VERSION (tag v$VERSION)"
|
||||||
|
echo " release date : $DATE"
|
||||||
|
echo " next cycle : $NEXT_DEV"
|
||||||
|
echo
|
||||||
|
echo "Unreleased notes that will become the v$VERSION release notes:"
|
||||||
|
echo "$UNRELEASED_BODY" | sed 's/^/ | /'
|
||||||
|
echo
|
||||||
|
read -r -p "Proceed (commits, tag, and PUSH to origin)? [y/N] " ANSWER
|
||||||
|
case "$ANSWER" in
|
||||||
|
y | Y | yes | YES) ;;
|
||||||
|
*) die "aborted" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- 1. Promote CHANGELOG Unreleased -> this version -----------------------
|
||||||
|
|
||||||
|
node - "$VERSION" "$DATE" <<'NODE'
|
||||||
|
const fs = require("fs");
|
||||||
|
const [version, date] = process.argv.slice(2);
|
||||||
|
const file = "CHANGELOG.md";
|
||||||
|
let text = fs.readFileSync(file, "utf8");
|
||||||
|
|
||||||
|
if (text.includes(`## [${version}]`)) {
|
||||||
|
console.error(`release: CHANGELOG already has a [${version}] section`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the Unreleased heading with a fresh empty Unreleased + the new version.
|
||||||
|
text = text.replace(
|
||||||
|
/^## \[Unreleased\][^\n]*\n/m,
|
||||||
|
`## [Unreleased]\n\n## [${version}] - ${date}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh the link reference block at the bottom, if present.
|
||||||
|
const repo = "https://github.com/juherr/kill-the-news";
|
||||||
|
const unreleasedLink = `[Unreleased]: ${repo}/compare/v${version}...HEAD`;
|
||||||
|
if (/^\[Unreleased\]:/m.test(text)) {
|
||||||
|
text = text.replace(
|
||||||
|
/^\[Unreleased\]:.*$/m,
|
||||||
|
`${unreleasedLink}\n[${version}]: ${repo}/compare/PREV...v${version}`,
|
||||||
|
);
|
||||||
|
// Best-effort: point the new version diff at the previous tagged version.
|
||||||
|
const prev = [...text.matchAll(/^\[(\d+\.\d+\.\d+)\]:/gm)]
|
||||||
|
.map((m) => m[1])
|
||||||
|
.find((v) => v !== version);
|
||||||
|
if (prev) {
|
||||||
|
text = text.replace("compare/PREV...", `compare/v${prev}...`);
|
||||||
|
} else {
|
||||||
|
text = text.replace(`/compare/PREV...v${version}`, `/releases/tag/v${version}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(file, text);
|
||||||
|
console.log(`Updated CHANGELOG.md for ${version}`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
# --- 2. Release commit (bare version) + 3. tag -----------------------------
|
||||||
|
|
||||||
|
npm version "$VERSION" --no-git-tag-version --allow-same-version >/dev/null
|
||||||
|
git add package.json package-lock.json CHANGELOG.md
|
||||||
|
git commit -m "chore(release): $VERSION" >/dev/null
|
||||||
|
git tag "v$VERSION"
|
||||||
|
echo "Committed release v$VERSION and tagged it."
|
||||||
|
|
||||||
|
# --- 4. Open the next develop cycle ----------------------------------------
|
||||||
|
|
||||||
|
npm version "$NEXT_DEV" --no-git-tag-version >/dev/null
|
||||||
|
git add package.json package-lock.json
|
||||||
|
git commit -m "chore: open $NEXT_BASE develop cycle" >/dev/null
|
||||||
|
echo "Opened next cycle: $NEXT_DEV."
|
||||||
|
|
||||||
|
# --- 5. Push ----------------------------------------------------------------
|
||||||
|
|
||||||
|
git push origin main "v$VERSION"
|
||||||
|
echo
|
||||||
|
echo "Pushed main + v$VERSION. The Release workflow will publish the GitHub Release."
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { createMockEnv, MockR2, server } from "../test/setup";
|
import { createMockEnv, MockR2, seedInboundIndex, server } from "../test/setup";
|
||||||
import {
|
import {
|
||||||
processEmail,
|
processEmail,
|
||||||
ProcessEmailInput,
|
ProcessEmailInput,
|
||||||
@@ -30,8 +30,10 @@ function makeInput(
|
|||||||
describe("processEmail", () => {
|
describe("processEmail", () => {
|
||||||
let env: ReturnType<typeof createMockEnv>;
|
let env: ReturnType<typeof createMockEnv>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
env = createMockEnv();
|
env = createMockEnv();
|
||||||
|
// The inbound address resolves to a feed of the same id in these unit tests.
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when toAddress has no valid feedId", async () => {
|
it("returns 400 when toAddress has no valid feedId", async () => {
|
||||||
@@ -42,7 +44,18 @@ describe("processEmail", () => {
|
|||||||
expect(res).toMatchObject({ ok: false, reason: "invalid_address" });
|
expect(res).toMatchObject({ ok: false, reason: "invalid_address" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 when feed does not exist", async () => {
|
it("returns mailbox_unknown when no feed claims the inbound address", async () => {
|
||||||
|
// A well-formed mailbox (noun.noun.NN) that was never registered in the
|
||||||
|
// inbound index — distinct from a dangling index pointing at a missing feed.
|
||||||
|
const res = await processEmail(
|
||||||
|
makeInput({ toAddress: "unknown.mailbox.99@test.getmynews.app" }),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
expect(res).toMatchObject({ ok: false, reason: "mailbox_unknown" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns feed_not_found when the index resolves but the feed is gone", async () => {
|
||||||
|
// The inbound index is seeded (beforeEach) but no config exists for it.
|
||||||
const res = await processEmail(makeInput(), env as any);
|
const res = await processEmail(makeInput(), env as any);
|
||||||
expect(res).toMatchObject({ ok: false, reason: "feed_not_found" });
|
expect(res).toMatchObject({ ok: false, reason: "feed_not_found" });
|
||||||
});
|
});
|
||||||
@@ -319,14 +332,14 @@ describe("processEmail", () => {
|
|||||||
passThroughOnException: () => {},
|
passThroughOnException: () => {},
|
||||||
} as unknown as ExecutionContext;
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
// Feed ID is valid format but config doesn't exist → 404
|
// Well-formed mailbox but not registered → mailbox_unknown (an error path).
|
||||||
const res = await processEmail(
|
const res = await processEmail(
|
||||||
makeInput({ toAddress: `no.such.99@test.getmynews.app` }),
|
makeInput({ toAddress: `no.such.99@test.getmynews.app` }),
|
||||||
env as any,
|
env as any,
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res).toMatchObject({ ok: false, reason: "feed_not_found" });
|
expect(res).toMatchObject({ ok: false, reason: "mailbox_unknown" });
|
||||||
expect(waitUntilCalled).toBe(false);
|
expect(waitUntilCalled).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -343,6 +356,7 @@ describe("processEmail — attachments", () => {
|
|||||||
|
|
||||||
it("skips R2 upload when ATTACHMENT_BUCKET is not configured", async () => {
|
it("skips R2 upload when ATTACHMENT_BUCKET is not configured", async () => {
|
||||||
const env = createMockEnv();
|
const env = createMockEnv();
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
`feed:${VALID_FEED_ID}:config`,
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
JSON.stringify({}),
|
JSON.stringify({}),
|
||||||
@@ -366,6 +380,7 @@ describe("processEmail — attachments", () => {
|
|||||||
|
|
||||||
it("skips R2 upload when ATTACHMENTS_ENABLED is 'false' even with R2 bound", async () => {
|
it("skips R2 upload when ATTACHMENTS_ENABLED is 'false' even with R2 bound", async () => {
|
||||||
const env = createMockEnv({ withR2: true });
|
const env = createMockEnv({ withR2: true });
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
(env as any).ATTACHMENTS_ENABLED = "false";
|
(env as any).ATTACHMENTS_ENABLED = "false";
|
||||||
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
@@ -392,6 +407,7 @@ describe("processEmail — attachments", () => {
|
|||||||
|
|
||||||
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 });
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
`feed:${VALID_FEED_ID}:config`,
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
@@ -423,6 +439,7 @@ describe("processEmail — attachments", () => {
|
|||||||
|
|
||||||
it("stores attachmentIds in EmailMetadata for trim-time cleanup", async () => {
|
it("stores attachmentIds in EmailMetadata for trim-time cleanup", async () => {
|
||||||
const env = createMockEnv({ withR2: true });
|
const env = createMockEnv({ withR2: true });
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
`feed:${VALID_FEED_ID}:config`,
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
JSON.stringify({}),
|
JSON.stringify({}),
|
||||||
@@ -439,6 +456,7 @@ describe("processEmail — attachments", () => {
|
|||||||
|
|
||||||
it("classifies a cid-referenced image as inline, not a downloadable attachment", async () => {
|
it("classifies a cid-referenced image as inline, not a downloadable attachment", async () => {
|
||||||
const env = createMockEnv({ withR2: true });
|
const env = createMockEnv({ withR2: true });
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
`feed:${VALID_FEED_ID}:config`,
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
JSON.stringify({}),
|
JSON.stringify({}),
|
||||||
@@ -484,6 +502,7 @@ describe("processEmail — attachments", () => {
|
|||||||
|
|
||||||
it("deletes inline image R2 objects when a trimmed email had them", async () => {
|
it("deletes inline image R2 objects when a trimmed email had them", async () => {
|
||||||
const env = createMockEnv({ withR2: true });
|
const env = createMockEnv({ withR2: true });
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
`feed:${VALID_FEED_ID}:config`,
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
@@ -537,6 +556,7 @@ describe("processEmail — attachments", () => {
|
|||||||
|
|
||||||
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 });
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
const mockR2 = (env as any).ATTACHMENT_BUCKET as unknown as MockR2;
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
`feed:${VALID_FEED_ID}:config`,
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
@@ -595,9 +615,139 @@ describe("processEmail — attachments", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("processEmail — deduplication", () => {
|
||||||
|
let env: ReturnType<typeof createMockEnv>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
env = createMockEnv();
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
);
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores only one email when the same Message-ID is delivered twice", async () => {
|
||||||
|
const headers = { "Message-ID": "<abc123@example.com>" };
|
||||||
|
await processEmail(makeInput({ headers }), env as any);
|
||||||
|
await processEmail(makeInput({ headers }), env as any);
|
||||||
|
|
||||||
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
);
|
||||||
|
expect(metadata.emails).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments emails_deduplicated counter on the second delivery", async () => {
|
||||||
|
const headers = { "Message-ID": "<dup42@example.com>" };
|
||||||
|
await processEmail(makeInput({ headers }), env as any);
|
||||||
|
await processEmail(makeInput({ headers }), env as any);
|
||||||
|
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||||
|
expect(counters.emails_deduplicated).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates by hash when no Message-ID header is present", async () => {
|
||||||
|
const input = makeInput({
|
||||||
|
subject: "Weekly Digest",
|
||||||
|
content: "<p>Same content</p>",
|
||||||
|
});
|
||||||
|
await processEmail(input, env as any);
|
||||||
|
await processEmail(input, env as any);
|
||||||
|
|
||||||
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
);
|
||||||
|
expect(metadata.emails).toHaveLength(1);
|
||||||
|
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||||
|
expect(counters.emails_deduplicated).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not deduplicate emails with different subjects (no Message-ID)", async () => {
|
||||||
|
await processEmail(
|
||||||
|
makeInput({ subject: "First", content: "<p>body</p>" }),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
await processEmail(
|
||||||
|
makeInput({ subject: "Second", content: "<p>body</p>" }),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
);
|
||||||
|
expect(metadata.emails).toHaveLength(2);
|
||||||
|
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||||
|
expect(counters.emails_deduplicated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not false-positive against pre-feature entries lacking messageId/dedupHash", async () => {
|
||||||
|
// Seed a legacy metadata entry with no messageId or dedupHash
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
JSON.stringify({
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
key: `feed:${VALID_FEED_ID}:999`,
|
||||||
|
subject: "Old Subject",
|
||||||
|
receivedAt: 999,
|
||||||
|
size: 50,
|
||||||
|
// intentionally no messageId, no dedupHash
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A new, distinct email should be stored without triggering false dedup
|
||||||
|
const res = await processEmail(
|
||||||
|
makeInput({ subject: "New Distinct Email", content: "<p>fresh</p>" }),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
);
|
||||||
|
expect(metadata.emails).toHaveLength(2);
|
||||||
|
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||||
|
expect(counters.emails_deduplicated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns { ok: true } for a genuine duplicate (not a rejection)", async () => {
|
||||||
|
const headers = { "Message-ID": "<nodrop@example.com>" };
|
||||||
|
await processEmail(makeInput({ headers }), env as any);
|
||||||
|
const res = await processEmail(makeInput({ headers }), env as any);
|
||||||
|
expect(res).toMatchObject({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores messageId and dedupHash in the email metadata entry", async () => {
|
||||||
|
const headers = { "Message-ID": "<stored@example.com>" };
|
||||||
|
await processEmail(
|
||||||
|
makeInput({ subject: "Sub", content: "<p>c</p>", headers }),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
);
|
||||||
|
expect(metadata.emails[0].messageId).toBe("<stored@example.com>");
|
||||||
|
expect(typeof metadata.emails[0].dedupHash).toBe("string");
|
||||||
|
expect(metadata.emails[0].dedupHash).toHaveLength(64); // SHA-256 hex
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("processEmail — monitoring counters", () => {
|
describe("processEmail — monitoring counters", () => {
|
||||||
it("increments emails_received and sets last_email_at on success", async () => {
|
it("increments emails_received and sets last_email_at on success", async () => {
|
||||||
const env = createMockEnv();
|
const env = createMockEnv();
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
await env.EMAIL_STORAGE.put(
|
await env.EMAIL_STORAGE.put(
|
||||||
`feed:${VALID_FEED_ID}:config`,
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
JSON.stringify({}),
|
JSON.stringify({}),
|
||||||
@@ -632,6 +782,7 @@ describe("processEmail — feed icon", () => {
|
|||||||
`feed:${VALID_FEED_ID}:config`,
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
JSON.stringify({}),
|
JSON.stringify({}),
|
||||||
);
|
);
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists the latest sender domain on the feed metadata", async () => {
|
it("persists the latest sender domain on the feed metadata", async () => {
|
||||||
@@ -674,6 +825,82 @@ describe("processEmail — feed icon", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("processEmail — confirmation detection", () => {
|
||||||
|
let env: ReturnType<typeof createMockEnv>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
env = createMockEnv();
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
);
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a confirmation email and raises pendingConfirmation", async () => {
|
||||||
|
const result = await processEmail(
|
||||||
|
makeInput({
|
||||||
|
subject: "Please confirm your subscription",
|
||||||
|
content:
|
||||||
|
'<p>Click <a href="https://example.com/confirm?token=abc">Confirm</a></p>',
|
||||||
|
}),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
);
|
||||||
|
expect(metadata.pendingConfirmation).toBe(true);
|
||||||
|
expect(metadata.emails[0].confirmation?.links[0]).toBe(
|
||||||
|
"https://example.com/confirm?token=abc",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a plain-text confirmation email and raises pendingConfirmation", async () => {
|
||||||
|
const result = await processEmail(
|
||||||
|
makeInput({
|
||||||
|
subject: "Confirm your subscription",
|
||||||
|
content:
|
||||||
|
"Please confirm your subscription. Click here: https://example.com/confirm?token=xyz to verify your email.",
|
||||||
|
}),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
);
|
||||||
|
expect(metadata.pendingConfirmation).toBe(true);
|
||||||
|
expect(metadata.emails[0].confirmation?.links[0]).toBe(
|
||||||
|
"https://example.com/confirm?token=xyz",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark a regular newsletter as a confirmation", async () => {
|
||||||
|
const result = await processEmail(
|
||||||
|
makeInput({
|
||||||
|
subject: "Weekly Newsletter",
|
||||||
|
content: "<p>Here is your weekly digest of news.</p>",
|
||||||
|
}),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
const metadata = await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
);
|
||||||
|
expect(metadata.pendingConfirmation).toBeFalsy();
|
||||||
|
expect(metadata.emails[0].confirmation).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("processEmail — unsubscribe capture", () => {
|
describe("processEmail — unsubscribe capture", () => {
|
||||||
let env: ReturnType<typeof createMockEnv>;
|
let env: ReturnType<typeof createMockEnv>;
|
||||||
|
|
||||||
@@ -683,6 +910,7 @@ describe("processEmail — unsubscribe capture", () => {
|
|||||||
`feed:${VALID_FEED_ID}:config`,
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
JSON.stringify({}),
|
JSON.stringify({}),
|
||||||
);
|
);
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores the one-click unsubscribe URL on the feed metadata, keyed by sender", async () => {
|
it("stores the one-click unsubscribe URL on the feed metadata, keyed by sender", async () => {
|
||||||
@@ -709,6 +937,7 @@ describe("processEmail — unsubscribe capture", () => {
|
|||||||
it("keeps one entry per sender and overwrites with the latest URL", async () => {
|
it("keeps one entry per sender and overwrites with the latest URL", async () => {
|
||||||
await processEmail(
|
await processEmail(
|
||||||
makeInput({
|
makeInput({
|
||||||
|
subject: "Issue 1 from A",
|
||||||
senders: ["a@one.com"],
|
senders: ["a@one.com"],
|
||||||
headers: {
|
headers: {
|
||||||
"list-unsubscribe": "<https://one.com/u/1>",
|
"list-unsubscribe": "<https://one.com/u/1>",
|
||||||
@@ -719,6 +948,7 @@ describe("processEmail — unsubscribe capture", () => {
|
|||||||
);
|
);
|
||||||
await processEmail(
|
await processEmail(
|
||||||
makeInput({
|
makeInput({
|
||||||
|
subject: "Issue 1 from B",
|
||||||
senders: ["b@two.com"],
|
senders: ["b@two.com"],
|
||||||
headers: {
|
headers: {
|
||||||
"list-unsubscribe": "<https://two.com/u/1>",
|
"list-unsubscribe": "<https://two.com/u/1>",
|
||||||
@@ -729,6 +959,7 @@ describe("processEmail — unsubscribe capture", () => {
|
|||||||
);
|
);
|
||||||
await processEmail(
|
await processEmail(
|
||||||
makeInput({
|
makeInput({
|
||||||
|
subject: "Issue 2 from A",
|
||||||
senders: ["a@one.com"],
|
senders: ["a@one.com"],
|
||||||
headers: {
|
headers: {
|
||||||
"list-unsubscribe": "<https://one.com/u/2>",
|
"list-unsubscribe": "<https://one.com/u/2>",
|
||||||
@@ -763,3 +994,81 @@ describe("processEmail — unsubscribe capture", () => {
|
|||||||
expect(metadata.unsubscribe).toBeUndefined();
|
expect(metadata.unsubscribe).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("native feed detection on ingest", () => {
|
||||||
|
let env: ReturnType<typeof createMockEnv>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
env = createMockEnv();
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
);
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores detected native feeds on the feed metadata (TEST A)", async () => {
|
||||||
|
const result = await processEmail(
|
||||||
|
makeInput({
|
||||||
|
from: "news@blog.example.com",
|
||||||
|
senders: ["news@blog.example.com"],
|
||||||
|
content:
|
||||||
|
'<html><head><link rel="alternate" type="application/rss+xml" href="https://blog.example.com/feed.xml"></head><body>hello</body></html>',
|
||||||
|
}),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
const metadata = (await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
)) as {
|
||||||
|
nativeFeeds?: Record<string, Array<{ url: string; type: string }>>;
|
||||||
|
};
|
||||||
|
expect(Object.values(metadata.nativeFeeds!).flat()).toEqual([
|
||||||
|
{ url: "https://blog.example.com/feed.xml", type: "rss" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not store nativeFeeds when no feed links are found (TEST B)", async () => {
|
||||||
|
const result = await processEmail(
|
||||||
|
makeInput({
|
||||||
|
content: "<p>no feed here</p>",
|
||||||
|
}),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
const metadata = (await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
)) as { nativeFeeds?: Record<string, unknown> };
|
||||||
|
expect(metadata.nativeFeeds).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("absolutizes a relative feed href using the display-name sender's domain (TEST C)", async () => {
|
||||||
|
const result = await processEmail(
|
||||||
|
makeInput({
|
||||||
|
from: "Blog Name <news@blog.example.com>",
|
||||||
|
senders: ["news@blog.example.com"],
|
||||||
|
content:
|
||||||
|
'<html><head><link rel="alternate" type="application/atom+xml" href="/atom.xml"></head><body>hi</body></html>',
|
||||||
|
}),
|
||||||
|
env as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
const metadata = (await env.EMAIL_STORAGE.get(
|
||||||
|
`feed:${VALID_FEED_ID}:metadata`,
|
||||||
|
"json",
|
||||||
|
)) as {
|
||||||
|
nativeFeeds?: Record<string, Array<{ url: string; type: string }>>;
|
||||||
|
};
|
||||||
|
expect(Object.values(metadata.nativeFeeds!).flat()).toEqual([
|
||||||
|
{ url: "https://blog.example.com/atom.xml", type: "atom" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { EmailParser } from "../domain/email-parser";
|
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||||
|
import { EmailAddress } from "../domain/value-objects/email-address";
|
||||||
import { AttachmentData, EmailMetadata, Env } from "../types";
|
import { AttachmentData, EmailMetadata, Env } from "../types";
|
||||||
import { bumpCounters } from "../application/stats";
|
import { bumpCounters } from "../application/stats";
|
||||||
import { dispatchFeedEvents } from "../application/feed-events";
|
import { dispatchFeedEvents } from "../application/feed-events";
|
||||||
import { extractEmailDomain } from "../infrastructure/favicon-fetcher";
|
|
||||||
import { parseOneClickUnsubscribe } from "../infrastructure/unsubscribe";
|
import { parseOneClickUnsubscribe } from "../infrastructure/unsubscribe";
|
||||||
import { getAttachmentBucket } from "../infrastructure/attachments";
|
import { getAttachmentBucket } from "../infrastructure/attachments";
|
||||||
import { extractInlineCids } from "../infrastructure/html-processor";
|
import {
|
||||||
|
extractFeedLinks,
|
||||||
|
extractInlineCids,
|
||||||
|
extractLinks,
|
||||||
|
htmlToText,
|
||||||
|
} from "../infrastructure/html-processor";
|
||||||
|
import { detectConfirmation } from "../domain/confirmation";
|
||||||
|
import { detectNativeFeeds } from "../domain/native-feed";
|
||||||
import { attachmentIdsForCleanup } from "./feed-cleanup";
|
import { attachmentIdsForCleanup } from "./feed-cleanup";
|
||||||
import { FeedRepository } from "../infrastructure/feed-repository";
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
import { BackgroundScheduler } from "../infrastructure/worker";
|
import { BackgroundScheduler } from "../infrastructure/worker";
|
||||||
@@ -33,6 +40,7 @@ export interface ProcessEmailInput {
|
|||||||
|
|
||||||
export type IngestRejectionReason =
|
export type IngestRejectionReason =
|
||||||
| "invalid_address"
|
| "invalid_address"
|
||||||
|
| "mailbox_unknown"
|
||||||
| "feed_not_found"
|
| "feed_not_found"
|
||||||
| "feed_expired"
|
| "feed_expired"
|
||||||
| "sender_blocked";
|
| "sender_blocked";
|
||||||
@@ -79,17 +87,33 @@ async function loadAcceptingFeed(
|
|||||||
): Promise<
|
): Promise<
|
||||||
{ ok: true; feed: Feed } | { ok: false; reason: IngestRejectionReason }
|
{ ok: true; feed: Feed } | { ok: false; reason: IngestRejectionReason }
|
||||||
> {
|
> {
|
||||||
const feedId = EmailParser.extractFeedId(input.toAddress);
|
// MailboxId.parse is the single boundary where an untrusted inbound address
|
||||||
if (!feedId) {
|
// (the most untrusted input in the system) becomes a validated mailbox.
|
||||||
|
const mailbox = MailboxId.parse(input.toAddress);
|
||||||
|
if (!mailbox) {
|
||||||
logger.error("Invalid email address format", {
|
logger.error("Invalid email address format", {
|
||||||
toAddress: input.toAddress,
|
toAddress: input.toAddress,
|
||||||
});
|
});
|
||||||
return { ok: false, reason: "invalid_address" };
|
return { ok: false, reason: "invalid_address" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const feed = await FeedRepository.from(env).load(feedId);
|
// Resolve the inbound mailbox to the feed's opaque id (decoupled identities).
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
const feedId = await repo.resolveInbound(mailbox);
|
||||||
|
if (!feedId) {
|
||||||
|
// No feed claims this address — the common "wrong/unknown alias" case.
|
||||||
|
logger.error("Unknown inbound mailbox", { mailbox: mailbox.value });
|
||||||
|
return { ok: false, reason: "mailbox_unknown" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const feed = await repo.load(feedId);
|
||||||
if (!feed) {
|
if (!feed) {
|
||||||
logger.error("Feed not found", { feedId: feedId.value });
|
// The index resolved but the feed is gone — a dangling index (should be
|
||||||
|
// near-impossible now the index is dropped on feed deletion).
|
||||||
|
logger.error("Feed not found", {
|
||||||
|
mailbox: mailbox.value,
|
||||||
|
feedId: feedId.value,
|
||||||
|
});
|
||||||
return { ok: false, reason: "feed_not_found" };
|
return { ok: false, reason: "feed_not_found" };
|
||||||
}
|
}
|
||||||
if (feed.isExpired()) {
|
if (feed.isExpired()) {
|
||||||
@@ -109,12 +133,73 @@ async function loadAcceptingFeed(
|
|||||||
return { ok: true, feed };
|
return { ok: true, feed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a SHA-256 hex digest of a normalised string combining subject and
|
||||||
|
* content. Used as a dedup fallback when no Message-ID header is present.
|
||||||
|
* "Normalised" means lower-cased and all whitespace runs collapsed to a single
|
||||||
|
* space — so minor whitespace differences in re-sent mails still match.
|
||||||
|
*/
|
||||||
|
async function computeDedupHash(
|
||||||
|
subject: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
||||||
|
const raw = `${normalize(subject)}\n${normalize(content)}`;
|
||||||
|
const buf = await crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
new TextEncoder().encode(raw),
|
||||||
|
);
|
||||||
|
return Array.from(new Uint8Array(buf))
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the Message-ID from request headers (case-insensitive key lookup).
|
||||||
|
* Returns undefined when absent or empty.
|
||||||
|
*/
|
||||||
|
function extractMessageId(
|
||||||
|
headers: Record<string, string> | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!headers) return undefined;
|
||||||
|
const value = Object.entries(headers).find(
|
||||||
|
([k]) => k.toLowerCase() === "message-id",
|
||||||
|
)?.[1];
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function storeEmail(
|
async function storeEmail(
|
||||||
feed: Feed,
|
feed: Feed,
|
||||||
input: ProcessEmailInput,
|
input: ProcessEmailInput,
|
||||||
env: Env,
|
env: Env,
|
||||||
ctx?: ExecutionContext,
|
ctx?: ExecutionContext,
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
|
// ── Dedup check ──────────────────────────────────────────────────────────
|
||||||
|
// Compute both dedup signals up-front (hash is async) so we only do it once.
|
||||||
|
const messageId = extractMessageId(input.headers);
|
||||||
|
const dedupHash = await computeDedupHash(input.subject, input.content);
|
||||||
|
|
||||||
|
if (feed.hasDuplicate(messageId, dedupHash)) {
|
||||||
|
logger.info("Duplicate email skipped", {
|
||||||
|
feedId: feed.id.value,
|
||||||
|
...(messageId ? { messageId } : { dedupHash }),
|
||||||
|
});
|
||||||
|
await bumpCounters(env.EMAIL_STORAGE, { emails_deduplicated: 1 });
|
||||||
|
return false; // signal: skipped (not stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmationLinks = detectConfirmation({
|
||||||
|
subject: input.subject,
|
||||||
|
text: htmlToText(input.content),
|
||||||
|
links: extractLinks(input.content),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sender = EmailAddress.parse(input.from);
|
||||||
|
const nativeFeedList = detectNativeFeeds(
|
||||||
|
extractFeedLinks(input.content, sender?.siteBaseUrl() ?? ""),
|
||||||
|
);
|
||||||
|
|
||||||
const attachmentBucket = getAttachmentBucket(env);
|
const attachmentBucket = getAttachmentBucket(env);
|
||||||
const inlineCids = extractInlineCids(input.content);
|
const inlineCids = extractInlineCids(input.content);
|
||||||
const storedAttachments: AttachmentData[] =
|
const storedAttachments: AttachmentData[] =
|
||||||
@@ -149,19 +234,20 @@ async function storeEmail(
|
|||||||
size: serialisedSize,
|
size: serialisedSize,
|
||||||
...(downloadableIds.length > 0 ? { attachmentIds: downloadableIds } : {}),
|
...(downloadableIds.length > 0 ? { attachmentIds: downloadableIds } : {}),
|
||||||
...(inlineIds.length > 0 ? { inlineAttachmentIds: inlineIds } : {}),
|
...(inlineIds.length > 0 ? { inlineAttachmentIds: inlineIds } : {}),
|
||||||
|
...(messageId ? { messageId } : {}),
|
||||||
|
dedupHash,
|
||||||
|
...(confirmationLinks
|
||||||
|
? { confirmation: { links: confirmationLinks } }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track the latest sender's domain (feed icon) and capture the RFC 8058
|
// 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
|
// one-click unsubscribe link, keyed by sender so each newsletter keeps its
|
||||||
// own latest URL (fired when the feed is deleted).
|
// own latest URL (fired when the feed is deleted).
|
||||||
const iconDomain = extractEmailDomain(input.from);
|
const iconDomain = sender?.domain.value;
|
||||||
|
const senderKey = input.senders[0] || iconDomain || input.from;
|
||||||
const unsubUrl = parseOneClickUnsubscribe(input.headers ?? {});
|
const unsubUrl = parseOneClickUnsubscribe(input.headers ?? {});
|
||||||
const unsub = unsubUrl
|
const unsub = unsubUrl ? { senderKey, url: unsubUrl } : undefined;
|
||||||
? {
|
|
||||||
senderKey: input.senders[0] || iconDomain || input.from,
|
|
||||||
url: unsubUrl,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const maxBytes =
|
const maxBytes =
|
||||||
parseInt(env.FEED_MAX_SIZE_BYTES ?? "", 10) || FEED_MAX_BYTES;
|
parseInt(env.FEED_MAX_SIZE_BYTES ?? "", 10) || FEED_MAX_BYTES;
|
||||||
@@ -170,6 +256,9 @@ async function storeEmail(
|
|||||||
maxBytes,
|
maxBytes,
|
||||||
iconDomain: iconDomain ?? undefined,
|
iconDomain: iconDomain ?? undefined,
|
||||||
unsub,
|
unsub,
|
||||||
|
...(nativeFeedList.length > 0
|
||||||
|
? { nativeFeeds: { senderKey, feeds: nativeFeedList } }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const r2Deletions =
|
const r2Deletions =
|
||||||
@@ -198,6 +287,7 @@ async function storeEmail(
|
|||||||
? (p) => ctx.waitUntil(p)
|
? (p) => ctx.waitUntil(p)
|
||||||
: () => {};
|
: () => {};
|
||||||
await dispatchFeedEvents(feed, env, schedule);
|
await dispatchFeedEvents(feed, env, schedule);
|
||||||
|
return true; // signal: stored
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function processEmail(
|
export async function processEmail(
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export async function fetchFeedData(
|
|||||||
title: `Newsletter Feed ${feedId.value}`,
|
title: `Newsletter Feed ${feedId.value}`,
|
||||||
description: "Converted email newsletter",
|
description: "Converted email newsletter",
|
||||||
language: "en",
|
language: "en",
|
||||||
|
// Read-model fallback only: the RSS/Atom/JSON path never builds the inbound
|
||||||
|
// address, so an empty mailbox is inert here (the real one lives on config).
|
||||||
|
mailbox_id: "",
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { createMockEnv } from "../test/setup";
|
import { createMockEnv } from "../test/setup";
|
||||||
import { createFeedRecord, editFeed } from "./feed-service";
|
import {
|
||||||
|
createFeedRecord,
|
||||||
|
editFeed,
|
||||||
|
deleteFeedRecord,
|
||||||
|
deleteFeedFastDetailed,
|
||||||
|
} from "./feed-service";
|
||||||
import { getCounters } from "./stats";
|
import { getCounters } from "./stats";
|
||||||
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
import { FeedId } from "../domain/value-objects/feed-id";
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||||
import type { Env } from "../types";
|
import type { Env } from "../types";
|
||||||
|
|
||||||
const mkEnv = (overrides: Partial<Env> = {}) =>
|
const mkEnv = (overrides: Partial<Env> = {}) =>
|
||||||
@@ -54,6 +61,40 @@ describe("createFeedRecord — TTL policy", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("deleting a feed drops its inbound mailbox index", () => {
|
||||||
|
it("deleteFeedRecord removes the inbound index so the address stops resolving", async () => {
|
||||||
|
const env = mkEnv();
|
||||||
|
const { feedId, mailboxId } = await createFeedRecord(env, { ...baseInput });
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
|
||||||
|
// Sanity: the address resolves to the feed before deletion.
|
||||||
|
expect(
|
||||||
|
(await repo.resolveInbound(MailboxId.unchecked(mailboxId)))?.value,
|
||||||
|
).toBe(feedId);
|
||||||
|
|
||||||
|
await deleteFeedRecord(env, FeedId.unchecked(feedId), () => {});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await repo.resolveInbound(MailboxId.unchecked(mailboxId)),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the bulk path (deleteFeedFastDetailed + removeFromListBulk) clears the inbound index", async () => {
|
||||||
|
const env = mkEnv();
|
||||||
|
const { feedId, mailboxId } = await createFeedRecord(env, { ...baseInput });
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
|
||||||
|
// The bulk admin path drops config/metadata, then removes from the list —
|
||||||
|
// the latter is what clears the inbound index (symmetric with save()).
|
||||||
|
await deleteFeedFastDetailed(env.EMAIL_STORAGE, FeedId.unchecked(feedId));
|
||||||
|
await repo.removeFromListBulk([feedId]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await repo.resolveInbound(MailboxId.unchecked(mailboxId)),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("editFeed — TTL policy", () => {
|
describe("editFeed — TTL policy", () => {
|
||||||
it("recomputes expiry from the server override on edit", async () => {
|
it("recomputes expiry from the server override on edit", async () => {
|
||||||
const env = mkEnv({ FEED_TTL_HOURS: "1" });
|
const env = mkEnv({ FEED_TTL_HOURS: "1" });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FeedRepository } from "../infrastructure/feed-repository";
|
|||||||
import { toConfigDTO } from "../infrastructure/feed-mapper";
|
import { toConfigDTO } from "../infrastructure/feed-mapper";
|
||||||
import { BackgroundScheduler } from "../infrastructure/worker";
|
import { BackgroundScheduler } from "../infrastructure/worker";
|
||||||
import { FeedId } from "../domain/value-objects/feed-id";
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||||
import { Lifetime } from "../domain/value-objects/lifetime";
|
import { Lifetime } from "../domain/value-objects/lifetime";
|
||||||
import {
|
import {
|
||||||
Feed,
|
Feed,
|
||||||
@@ -33,24 +34,32 @@ function resolveLifetime(env: Env, requestedHours?: number): Lifetime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a feed: write its config + empty metadata, register it in the global
|
* Create a feed: mint an opaque `FeedId` (the read id) and a friendly `MailboxId`
|
||||||
* list, and bump the `feeds_created` counter. Returns the new feed id + config.
|
* (the inbound address), write its config + empty metadata, register it in the
|
||||||
|
* global list + inbound index, and bump the `feeds_created` counter. Returns the
|
||||||
|
* new feed id, its mailbox, and config.
|
||||||
*/
|
*/
|
||||||
export async function createFeedRecord(
|
export async function createFeedRecord(
|
||||||
env: Env,
|
env: Env,
|
||||||
input: CreateFeedInput,
|
input: CreateFeedInput,
|
||||||
): Promise<{ feedId: string; config: FeedConfig }> {
|
): Promise<{ feedId: string; mailboxId: string; config: FeedConfig }> {
|
||||||
const repo = FeedRepository.from(env);
|
const repo = FeedRepository.from(env);
|
||||||
const feed = Feed.create(FeedId.generate(), input, {
|
const feed = Feed.create(FeedId.generate(), input, {
|
||||||
|
mailboxId: MailboxId.generate(),
|
||||||
lifetime: resolveLifetime(env, input.lifetimeHours),
|
lifetime: resolveLifetime(env, input.lifetimeHours),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// save() also writes the inbound:<mailbox> → feedId index.
|
||||||
await repo.save(feed);
|
await repo.save(feed);
|
||||||
|
|
||||||
// FeedCreated → bumps the feeds_created counter (no background work to schedule).
|
// FeedCreated → bumps the feeds_created counter (no background work to schedule).
|
||||||
await dispatchFeedEvents(feed, env, () => {});
|
await dispatchFeedEvents(feed, env, () => {});
|
||||||
|
|
||||||
return { feedId: feed.id.value, config: toConfigDTO(feed.state()) };
|
return {
|
||||||
|
feedId: feed.id.value,
|
||||||
|
mailboxId: feed.mailboxId.value,
|
||||||
|
config: toConfigDTO(feed.state()),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateFeedResult =
|
export type UpdateFeedResult =
|
||||||
@@ -118,8 +127,10 @@ type DeleteFeedFastResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a feed's config + metadata keys, reporting per-key outcomes. The
|
* Delete a feed's config + metadata keys, reporting per-key outcomes. The larger
|
||||||
* larger email/attachment cleanup is handled separately via purgeFeedKeysStep.
|
* email/attachment cleanup is handled separately via purgeFeedKeysStep, and the
|
||||||
|
* inbound `inbound:<mailbox>` index is dropped by `removeFromList(Bulk)` (which
|
||||||
|
* every caller invokes next) — symmetric with `save()` writing it.
|
||||||
*/
|
*/
|
||||||
export async function deleteFeedFastDetailed(
|
export async function deleteFeedFastDetailed(
|
||||||
emailStorage: KVNamespace,
|
emailStorage: KVNamespace,
|
||||||
@@ -166,6 +177,7 @@ export async function deleteFeedRecord(
|
|||||||
const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId);
|
const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId);
|
||||||
|
|
||||||
await deleteFeedFastDetailed(emailStorage, feedId);
|
await deleteFeedFastDetailed(emailStorage, feedId);
|
||||||
|
// removeFromList also drops the feed's inbound mailbox index.
|
||||||
const removed = await repo.removeFromList(feedId);
|
const removed = await repo.removeFromList(feedId);
|
||||||
if (removed) {
|
if (removed) {
|
||||||
await bumpCounters(emailStorage, { feeds_deleted: 1 });
|
await bumpCounters(emailStorage, { feeds_deleted: 1 });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Counters, Env, StatsResponse } from "../types";
|
import { Counters, Env, StatsResponse } from "../types";
|
||||||
|
import { APP_VERSION } from "../config/version";
|
||||||
import { logger } from "../infrastructure/logger";
|
import { logger } from "../infrastructure/logger";
|
||||||
import { FeedRepository } from "../infrastructure/feed-repository";
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
import { CountersRepository } from "../infrastructure/counters-repository";
|
import { CountersRepository } from "../infrastructure/counters-repository";
|
||||||
@@ -11,6 +12,8 @@ const EMPTY_COUNTERS: Counters = {
|
|||||||
feeds_deleted: 0,
|
feeds_deleted: 0,
|
||||||
emails_received: 0,
|
emails_received: 0,
|
||||||
emails_rejected: 0,
|
emails_rejected: 0,
|
||||||
|
emails_forwarded: 0,
|
||||||
|
emails_deduplicated: 0,
|
||||||
unsubscribes_sent: 0,
|
unsubscribes_sent: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,6 +44,8 @@ export async function bumpCounters(
|
|||||||
current.feeds_deleted += changes.feeds_deleted ?? 0;
|
current.feeds_deleted += changes.feeds_deleted ?? 0;
|
||||||
current.emails_received += changes.emails_received ?? 0;
|
current.emails_received += changes.emails_received ?? 0;
|
||||||
current.emails_rejected += changes.emails_rejected ?? 0;
|
current.emails_rejected += changes.emails_rejected ?? 0;
|
||||||
|
current.emails_forwarded += changes.emails_forwarded ?? 0;
|
||||||
|
current.emails_deduplicated += changes.emails_deduplicated ?? 0;
|
||||||
current.unsubscribes_sent += changes.unsubscribes_sent ?? 0;
|
current.unsubscribes_sent += changes.unsubscribes_sent ?? 0;
|
||||||
if (changes.last_email_at) current.last_email_at = changes.last_email_at;
|
if (changes.last_email_at) current.last_email_at = changes.last_email_at;
|
||||||
if (changes.last_feed_created_at)
|
if (changes.last_feed_created_at)
|
||||||
@@ -73,6 +78,7 @@ export async function getStats(env: Env): Promise<StatsResponse> {
|
|||||||
active_feeds: feeds.length,
|
active_feeds: feeds.length,
|
||||||
websub_subscriptions_active: websubCount,
|
websub_subscriptions_active: websubCount,
|
||||||
attachments_enabled: !!getAttachmentBucket(env),
|
attachments_enabled: !!getAttachmentBucket(env),
|
||||||
|
version: APP_VERSION,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The running app version, inlined from package.json at bundle time (the Worker
|
||||||
|
* has no filesystem at runtime). Surfaced in the admin/status footer and the
|
||||||
|
* /health JSON so a self-hoster can tell which build is deployed.
|
||||||
|
*/
|
||||||
|
export const APP_VERSION: string = pkg.version;
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { detectConfirmation } from "./confirmation";
|
||||||
|
|
||||||
|
describe("detectConfirmation", () => {
|
||||||
|
it("detects an English confirmation email and returns the confirm link", () => {
|
||||||
|
const result = detectConfirmation({
|
||||||
|
subject: "Please confirm your subscription",
|
||||||
|
text: "Click the button below to verify your email address.",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: "https://news.example.com/confirm?token=abc123",
|
||||||
|
text: "Confirm subscription",
|
||||||
|
},
|
||||||
|
{ href: "https://news.example.com/home", text: "Home" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result![0]).toBe("https://news.example.com/confirm?token=abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects a French confirmation email (accent-insensitive)", () => {
|
||||||
|
const result = detectConfirmation({
|
||||||
|
subject: "Confirmez votre inscription",
|
||||||
|
text: "Cliquez pour activer votre abonnement.",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: "https://lettre.example.fr/valider/xyz",
|
||||||
|
text: "Valider mon inscription",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result![0]).toBe("https://lettre.example.fr/valider/xyz");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a normal newsletter with only an unsubscribe link", () => {
|
||||||
|
const result = detectConfirmation({
|
||||||
|
subject: "This week in tech",
|
||||||
|
text: "Here are the top stories. To stop receiving these, unsubscribe here.",
|
||||||
|
links: [
|
||||||
|
{ href: "https://news.example.com/article/42", text: "Read more" },
|
||||||
|
{
|
||||||
|
href: "https://news.example.com/unsubscribe?u=9",
|
||||||
|
text: "Unsubscribe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no candidate link is present even if the subject matches", () => {
|
||||||
|
const result = detectConfirmation({
|
||||||
|
subject: "Confirm your subscription",
|
||||||
|
text: "Reply to this email to confirm.",
|
||||||
|
links: [],
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never treats an unsubscribe link as a confirmation candidate", () => {
|
||||||
|
const result = detectConfirmation({
|
||||||
|
subject: "Confirm your email",
|
||||||
|
text: "Verify your address.",
|
||||||
|
links: [
|
||||||
|
{ href: "https://x.example/verify/abc", text: "Verify email" },
|
||||||
|
{ href: "https://x.example/unsubscribe", text: "unsubscribe" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!).not.toContain("https://x.example/unsubscribe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks the strongest candidate first and caps at three links", () => {
|
||||||
|
const result = detectConfirmation({
|
||||||
|
subject: "Confirm your subscription",
|
||||||
|
text: "verify activate",
|
||||||
|
links: [
|
||||||
|
{ href: "https://x.example/help", text: "help" },
|
||||||
|
{ href: "https://x.example/a?token=1", text: "click" },
|
||||||
|
{ href: "https://x.example/confirm?token=2", text: "Confirm" },
|
||||||
|
{ href: "https://x.example/activate", text: "Activate account" },
|
||||||
|
{ href: "https://x.example/verify", text: "Verify" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.length).toBeLessThanOrEqual(3);
|
||||||
|
expect(result![0]).toBe("https://x.example/confirm?token=2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-http(s) links", () => {
|
||||||
|
const result = detectConfirmation({
|
||||||
|
subject: "Confirm your subscription",
|
||||||
|
text: "verify",
|
||||||
|
links: [{ href: "mailto:confirm@x.example", text: "confirm" }],
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── False-positive guards: ordinary newsletters must NOT be flagged ──────────
|
||||||
|
// A "manage subscription" footer link is only a weak signal (+1), so a stray
|
||||||
|
// body keyword (active/valid) cannot push it over the threshold.
|
||||||
|
|
||||||
|
it("does not flag a newsletter with a manage-subscription footer + 'active' in body", () => {
|
||||||
|
const result = detectConfirmation({
|
||||||
|
subject: "This week in tech",
|
||||||
|
text: "Thanks to our most active community members for the great discussion.",
|
||||||
|
links: [
|
||||||
|
{ href: "https://news.example.com/article/42", text: "Read more" },
|
||||||
|
{
|
||||||
|
href: "https://news.example.com/account/subscription",
|
||||||
|
text: "Manage your subscription",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag a newsletter with a subscription-preferences link + 'valid' in body", () => {
|
||||||
|
const result = detectConfirmation({
|
||||||
|
subject: "Weekend deals are here",
|
||||||
|
text: "These offers are valid until Friday — don't miss out.",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: "https://shop.example.com/subscription/preferences",
|
||||||
|
text: "Subscription preferences",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag a marketing 'Subscribe & save' CTA + 'activate' in body", () => {
|
||||||
|
const result = detectConfirmation({
|
||||||
|
subject: "Your weekly digest",
|
||||||
|
text: "Activate your free trial and start saving today.",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: "https://shop.example.com/subscribe",
|
||||||
|
text: "Subscribe & save",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Recall: a genuine confirmation still passes via the weak signal ──────────
|
||||||
|
it("detects a genuine confirm-subscription email whose only link is a bare /subscribe", () => {
|
||||||
|
const result = detectConfirmation({
|
||||||
|
subject: "Please confirm your subscription",
|
||||||
|
text: "Tap the button to finish signing up.",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: "https://news.example.com/subscribe/abc123",
|
||||||
|
text: "Subscribe",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result![0]).toBe("https://news.example.com/subscribe/abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes a confirmation link repeated in the body", () => {
|
||||||
|
const result = detectConfirmation({
|
||||||
|
subject: "Confirm your subscription",
|
||||||
|
text: "verify your address",
|
||||||
|
links: [
|
||||||
|
{ href: "https://x.example/confirm?token=1", text: "Confirm" },
|
||||||
|
{ href: "https://x.example/confirm?token=1", text: "Confirm here" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(result).toEqual(["https://x.example/confirm?token=1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Pure detection of "confirm your subscription" emails. No DOM, no I/O — it
|
||||||
|
* receives already-extracted subject/body text and link tuples (infra parses the
|
||||||
|
* HTML). This module owns the business knowledge: the multilingual keyword vocab,
|
||||||
|
* the link-signal patterns, the scoring weights and the threshold.
|
||||||
|
*
|
||||||
|
* Returns the ranked candidate confirmation links (top 3) when the combined score
|
||||||
|
* clears the threshold AND at least one candidate link exists; otherwise null.
|
||||||
|
* Only http(s) links are ever considered or returned.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DetectConfirmationInput {
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
links: { href: string; text: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmation-positive stems, already normalized (lowercased, diacritics stripped).
|
||||||
|
// EN / FR / DE / ES — extend here to add a language.
|
||||||
|
const KEYWORDS = [
|
||||||
|
"confirm",
|
||||||
|
"verif",
|
||||||
|
"activ",
|
||||||
|
"valid",
|
||||||
|
"bestatig",
|
||||||
|
"aktivier",
|
||||||
|
"opt-in",
|
||||||
|
"opt in",
|
||||||
|
"optin",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Strong URL signals: an unambiguous confirm/verify/activate action or a token.
|
||||||
|
// A link URL matching any scores +2.
|
||||||
|
const STRONG_LINK_SIGNALS = [
|
||||||
|
"confirm",
|
||||||
|
"verif",
|
||||||
|
"activ",
|
||||||
|
"valid",
|
||||||
|
"bestatig",
|
||||||
|
"aktivier",
|
||||||
|
"optin",
|
||||||
|
"opt-in",
|
||||||
|
"double-optin",
|
||||||
|
"token=",
|
||||||
|
"confirm=",
|
||||||
|
"activation",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Weak URL signals: ambiguous subscribe/subscription words that also appear in
|
||||||
|
// ordinary "manage subscription" footers. Worth only +1 so they cannot, on their
|
||||||
|
// own (with a stray body keyword), cross the threshold and cry wolf — but still
|
||||||
|
// let a genuine "confirm your subscription" subject + a bare /subscribe link pass.
|
||||||
|
const WEAK_LINK_SIGNALS = ["subscription", "subscribe"];
|
||||||
|
|
||||||
|
// Negative patterns: a link matching any of these is NEVER a candidate, and these
|
||||||
|
// tokens are stripped from text before keyword scanning (kills the unsubscribe
|
||||||
|
// false positive — "unsubscribe" contains "subscribe").
|
||||||
|
const NEGATIVE = [
|
||||||
|
"unsubscribe",
|
||||||
|
"desabonn",
|
||||||
|
"desinscri",
|
||||||
|
"abbestell",
|
||||||
|
"opt-out",
|
||||||
|
"optout",
|
||||||
|
"list-unsubscribe",
|
||||||
|
];
|
||||||
|
|
||||||
|
const THRESHOLD = 3;
|
||||||
|
|
||||||
|
function normalize(s: string): string {
|
||||||
|
return s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttp(href: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(href.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAny(haystack: string, needles: string[]): boolean {
|
||||||
|
return needles.some((n) => haystack.includes(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkScore(href: string, text: string): number {
|
||||||
|
const h = normalize(href);
|
||||||
|
const t = normalize(text);
|
||||||
|
if (matchesAny(h, NEGATIVE) || matchesAny(t, NEGATIVE)) return 0;
|
||||||
|
let score = 0;
|
||||||
|
if (matchesAny(h, STRONG_LINK_SIGNALS)) score += 2;
|
||||||
|
else if (matchesAny(h, WEAK_LINK_SIGNALS)) score += 1;
|
||||||
|
if (matchesAny(t, KEYWORDS)) score += 2;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripNegatives(text: string): string {
|
||||||
|
let out = text;
|
||||||
|
for (const n of NEGATIVE) out = out.split(n).join(" ");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectConfirmation(
|
||||||
|
input: DetectConfirmationInput,
|
||||||
|
): string[] | null {
|
||||||
|
const candidates = input.links
|
||||||
|
.filter((l) => isHttp(l.href))
|
||||||
|
.map((l) => ({ href: l.href.trim(), score: linkScore(l.href, l.text) }))
|
||||||
|
.filter((l) => l.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
|
const subject = stripNegatives(normalize(input.subject));
|
||||||
|
const text = stripNegatives(normalize(input.text));
|
||||||
|
|
||||||
|
const subjectScore = matchesAny(subject, KEYWORDS) ? 2 : 0;
|
||||||
|
const bodyScore = matchesAny(text, KEYWORDS) ? 1 : 0;
|
||||||
|
const bestLinkScore = candidates[0].score;
|
||||||
|
|
||||||
|
if (subjectScore + bodyScore + bestLinkScore < THRESHOLD) return null;
|
||||||
|
|
||||||
|
// Dedupe by href before capping, so a link repeated in the body never wastes
|
||||||
|
// one of the three surfaced slots.
|
||||||
|
return [...new Set(candidates.map((c) => c.href))].slice(0, 3);
|
||||||
|
}
|
||||||
@@ -1,37 +1,8 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { EmailParser } from "./email-parser";
|
import { EmailParser } from "./email-parser";
|
||||||
|
|
||||||
describe("EmailParser.extractFeedId", () => {
|
// Inbound mailbox parsing lives on the MailboxId VO (see mailbox-id.test.ts);
|
||||||
it("extracts a valid feed ID from an email address", () => {
|
// EmailParser no longer wraps it.
|
||||||
expect(
|
|
||||||
EmailParser.extractFeedId("river.castle.42@example.com")?.value,
|
|
||||||
).toBe("river.castle.42");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is case-insensitive for the local part", () => {
|
|
||||||
expect(
|
|
||||||
EmailParser.extractFeedId("River.Castle.42@example.com")?.value,
|
|
||||||
).toBe("River.Castle.42");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for an address with no feed ID format", () => {
|
|
||||||
expect(EmailParser.extractFeedId("user@example.com")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for a plain string without @", () => {
|
|
||||||
expect(EmailParser.extractFeedId("notanemail")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when the numeric suffix is only one digit", () => {
|
|
||||||
expect(EmailParser.extractFeedId("river.castle.4@example.com")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when the numeric suffix has more than two digits", () => {
|
|
||||||
expect(
|
|
||||||
EmailParser.extractFeedId("river.castle.123@example.com"),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("EmailParser.decodeEncodedWords", () => {
|
describe("EmailParser.decodeEncodedWords", () => {
|
||||||
it("returns plain text unchanged", () => {
|
it("returns plain text unchanged", () => {
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
import { EmailData } from "../types";
|
import { EmailData } from "../types";
|
||||||
import { FeedId } from "./value-objects/feed-id";
|
|
||||||
|
|
||||||
export class EmailParser {
|
export class EmailParser {
|
||||||
/**
|
|
||||||
* Extract the feed id from an inbound recipient address. Returns a validated
|
|
||||||
* `FeedId` value object (not a raw string) so the most untrusted input in the
|
|
||||||
* 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
|
||||||
static parseForwardEmailPayload(payload: any): EmailData {
|
static parseForwardEmailPayload(payload: any): EmailData {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export const feedKeys = {
|
|||||||
config: (feedId: string): string => `feed:${feedId}:config`,
|
config: (feedId: string): string => `feed:${feedId}:config`,
|
||||||
metadata: (feedId: string): string => `feed:${feedId}:metadata`,
|
metadata: (feedId: string): string => `feed:${feedId}:metadata`,
|
||||||
|
|
||||||
|
/** Secondary index: inbound mailbox local part → feed id (resolved at reception). */
|
||||||
|
inbound: (mailboxId: string): string => `inbound:${mailboxId}`,
|
||||||
|
|
||||||
/** Prefix covering every key owned by a feed (config, metadata, emails). */
|
/** Prefix covering every key owned by a feed (config, metadata, emails). */
|
||||||
feedPrefix: (feedId: string): string => `feed:${feedId}:`,
|
feedPrefix: (feedId: string): string => `feed:${feedId}:`,
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ export interface FeedState {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
/** The feed's inbound mailbox local part (`noun.noun.NN`) — its email address
|
||||||
|
* is `mailboxId@domain`. Decoupled from the feed's `FeedId` (the read id). */
|
||||||
|
mailboxId: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
/** When true, entry titles in the feed output are rendered as `[Sender] Subject`. */
|
||||||
|
senderInTitle?: boolean;
|
||||||
allowedSenders: string[];
|
allowedSenders: string[];
|
||||||
blockedSenders: string[];
|
blockedSenders: string[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { createMockEnv } from "../test/setup";
|
|||||||
import { Feed, CreateFeedInput } from "./feed.aggregate";
|
import { Feed, CreateFeedInput } from "./feed.aggregate";
|
||||||
import { FeedRepository } from "../infrastructure/feed-repository";
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
import { FeedId } from "./value-objects/feed-id";
|
import { FeedId } from "./value-objects/feed-id";
|
||||||
|
import { MailboxId } from "./value-objects/mailbox-id";
|
||||||
import { Lifetime } from "./value-objects/lifetime";
|
import { Lifetime } from "./value-objects/lifetime";
|
||||||
import { FeedState } from "./feed-state";
|
import { FeedState } from "./feed-state";
|
||||||
import { Clock } from "./clock";
|
import { Clock } from "./clock";
|
||||||
import type { Env, EmailMetadata } from "../types";
|
import type { Env, EmailMetadata } from "../types";
|
||||||
|
|
||||||
const FID = FeedId.unchecked("a.b.42");
|
const FID = FeedId.unchecked("opaque-feed-id");
|
||||||
|
const MBOX = MailboxId.unchecked("a.b.42");
|
||||||
|
|
||||||
const mockEnv = () => createMockEnv() as unknown as Env;
|
const mockEnv = () => createMockEnv() as unknown as Env;
|
||||||
|
|
||||||
@@ -27,6 +29,7 @@ const createInput = (
|
|||||||
const state = (overrides: Partial<FeedState> = {}): FeedState => ({
|
const state = (overrides: Partial<FeedState> = {}): FeedState => ({
|
||||||
title: "T",
|
title: "T",
|
||||||
language: "en",
|
language: "en",
|
||||||
|
mailboxId: "a.b.42",
|
||||||
allowedSenders: [],
|
allowedSenders: [],
|
||||||
blockedSenders: [],
|
blockedSenders: [],
|
||||||
createdAt: 0,
|
createdAt: 0,
|
||||||
@@ -43,8 +46,9 @@ const entry = (overrides: Partial<EmailMetadata> = {}): EmailMetadata => ({
|
|||||||
|
|
||||||
describe("Feed.create", () => {
|
describe("Feed.create", () => {
|
||||||
it("builds a config with an empty email index and no expiry by default", () => {
|
it("builds a config with an empty email index and no expiry by default", () => {
|
||||||
const feed = Feed.create(FID, createInput());
|
const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
|
||||||
expect(feed.id.value).toBe("a.b.42");
|
expect(feed.id.value).toBe("opaque-feed-id");
|
||||||
|
expect(feed.mailboxId.value).toBe("a.b.42");
|
||||||
expect(feed.title).toBe("News");
|
expect(feed.title).toBe("News");
|
||||||
expect(feed.expiresAt).toBeUndefined();
|
expect(feed.expiresAt).toBeUndefined();
|
||||||
expect(feed.emails).toEqual([]);
|
expect(feed.emails).toEqual([]);
|
||||||
@@ -53,6 +57,7 @@ describe("Feed.create", () => {
|
|||||||
it("resolves expiry from the supplied lifetime using the injected clock", () => {
|
it("resolves expiry from the supplied lifetime using the injected clock", () => {
|
||||||
const NOW = 1_000_000;
|
const NOW = 1_000_000;
|
||||||
const feed = Feed.create(FID, createInput(), {
|
const feed = Feed.create(FID, createInput(), {
|
||||||
|
mailboxId: MBOX,
|
||||||
clock: fixedClock(NOW),
|
clock: fixedClock(NOW),
|
||||||
lifetime: Lifetime.ofHours(2),
|
lifetime: Lifetime.ofHours(2),
|
||||||
});
|
});
|
||||||
@@ -64,18 +69,24 @@ describe("Feed.create", () => {
|
|||||||
it("trusts only deps.lifetime, not the client lifetimeHours field", () => {
|
it("trusts only deps.lifetime, not the client lifetimeHours field", () => {
|
||||||
// The aggregate no longer parses lifetime policy: the application resolves
|
// The aggregate no longer parses lifetime policy: the application resolves
|
||||||
// the effective Lifetime (env override etc.) and hands it in.
|
// the effective Lifetime (env override etc.) and hands it in.
|
||||||
const feed = Feed.create(FID, createInput({ lifetimeHours: 9999 }));
|
const feed = Feed.create(FID, createInput({ lifetimeHours: 9999 }), {
|
||||||
|
mailboxId: MBOX,
|
||||||
|
});
|
||||||
expect(feed.expiresAt).toBeUndefined();
|
expect(feed.expiresAt).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a non-positive lifetime as no expiry", () => {
|
it("treats a non-positive lifetime as no expiry", () => {
|
||||||
expect(
|
expect(
|
||||||
Feed.create(FID, createInput(), { lifetime: Lifetime.ofHours(0) })
|
Feed.create(FID, createInput(), {
|
||||||
.expiresAt,
|
mailboxId: MBOX,
|
||||||
|
lifetime: Lifetime.ofHours(0),
|
||||||
|
}).expiresAt,
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
Feed.create(FID, createInput(), { lifetime: Lifetime.ofHours(-5) })
|
Feed.create(FID, createInput(), {
|
||||||
.expiresAt,
|
mailboxId: MBOX,
|
||||||
|
lifetime: Lifetime.ofHours(-5),
|
||||||
|
}).expiresAt,
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -191,7 +202,7 @@ describe("Feed.removeEmails", () => {
|
|||||||
|
|
||||||
describe("Feed events", () => {
|
describe("Feed events", () => {
|
||||||
it("records FeedCreated on create and drains it once", () => {
|
it("records FeedCreated on create and drains it once", () => {
|
||||||
const feed = Feed.create(FID, createInput());
|
const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
|
||||||
expect(feed.pullEvents()).toEqual([{ type: "FeedCreated", feedId: FID }]);
|
expect(feed.pullEvents()).toEqual([{ type: "FeedCreated", feedId: FID }]);
|
||||||
// Draining clears: a second pull is empty.
|
// Draining clears: a second pull is empty.
|
||||||
expect(feed.pullEvents()).toEqual([]);
|
expect(feed.pullEvents()).toEqual([]);
|
||||||
@@ -222,21 +233,104 @@ describe("Feed events", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function newFeed(): Feed {
|
||||||
|
return Feed.create(
|
||||||
|
FeedId.generate(),
|
||||||
|
{
|
||||||
|
title: "T",
|
||||||
|
description: "",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId: MailboxId.unchecked("alpha.beta.10") },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmationEmail(
|
||||||
|
key: string,
|
||||||
|
confirmation?: { links: string[] },
|
||||||
|
): EmailMetadata {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
subject: "s",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
size: 10,
|
||||||
|
...(confirmation ? { confirmation } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Feed pendingConfirmation", () => {
|
||||||
|
it("is false on a fresh feed", () => {
|
||||||
|
expect(newFeed().pendingConfirmation).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is raised when a confirmation email is ingested", () => {
|
||||||
|
const feed = newFeed();
|
||||||
|
feed.ingest(confirmationEmail("k1", { links: ["https://x/confirm"] }), {
|
||||||
|
maxBytes: 1_000_000,
|
||||||
|
});
|
||||||
|
expect(feed.pendingConfirmation).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays false for a non-confirmation email", () => {
|
||||||
|
const feed = newFeed();
|
||||||
|
feed.ingest(confirmationEmail("k1"), { maxBytes: 1_000_000 });
|
||||||
|
expect(feed.pendingConfirmation).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is cleared by dismissConfirmation", () => {
|
||||||
|
const feed = newFeed();
|
||||||
|
feed.ingest(confirmationEmail("k1", { links: ["https://x/confirm"] }), {
|
||||||
|
maxBytes: 1_000_000,
|
||||||
|
});
|
||||||
|
feed.dismissConfirmation();
|
||||||
|
expect(feed.pendingConfirmation).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-raise after dismiss when removing an unrelated email", () => {
|
||||||
|
const feed = newFeed();
|
||||||
|
feed.ingest(confirmationEmail("k1", { links: ["https://x/confirm"] }), {
|
||||||
|
maxBytes: 1_000_000,
|
||||||
|
});
|
||||||
|
feed.ingest(confirmationEmail("k2"), { maxBytes: 1_000_000 });
|
||||||
|
feed.dismissConfirmation();
|
||||||
|
feed.removeEmails(["k2"]);
|
||||||
|
expect(feed.pendingConfirmation).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears when the last confirmation email is removed", () => {
|
||||||
|
const feed = newFeed();
|
||||||
|
feed.ingest(confirmationEmail("k1", { links: ["https://x/confirm"] }), {
|
||||||
|
maxBytes: 1_000_000,
|
||||||
|
});
|
||||||
|
feed.removeEmails(["k1"]);
|
||||||
|
expect(feed.pendingConfirmation).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("FeedRepository.load / save round-trip", () => {
|
describe("FeedRepository.load / save round-trip", () => {
|
||||||
it("persists a created feed and reflects later mutations", async () => {
|
it("persists a created feed and reflects later mutations", async () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
const created = Feed.create(FID, createInput({ title: "Round" }));
|
const created = Feed.create(FID, createInput({ title: "Round" }), {
|
||||||
|
mailboxId: MBOX,
|
||||||
|
});
|
||||||
await repo.save(created);
|
await repo.save(created);
|
||||||
|
|
||||||
const loaded = await repo.load(FID);
|
const loaded = await repo.load(FID);
|
||||||
expect(loaded).not.toBeNull();
|
expect(loaded).not.toBeNull();
|
||||||
expect(loaded!.title).toBe("Round");
|
expect(loaded!.title).toBe("Round");
|
||||||
|
expect(loaded!.mailboxId.value).toBe("a.b.42");
|
||||||
|
|
||||||
loaded!.ingest(entry({ key: "feed:a.b.42:1" }), { maxBytes: 1_000_000 });
|
loaded!.ingest(entry({ key: "feed:opaque-feed-id:1" }), {
|
||||||
|
maxBytes: 1_000_000,
|
||||||
|
});
|
||||||
await repo.saveMetadata(loaded!);
|
await repo.saveMetadata(loaded!);
|
||||||
|
|
||||||
const reloaded = await repo.load(FID);
|
const reloaded = await repo.load(FID);
|
||||||
expect(reloaded!.emails.map((e) => e.key)).toEqual(["feed:a.b.42:1"]);
|
expect(reloaded!.emails.map((e) => e.key)).toEqual([
|
||||||
|
"feed:opaque-feed-id:1",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when the feed has no config", async () => {
|
it("returns null when the feed has no config", async () => {
|
||||||
@@ -244,3 +338,76 @@ describe("FeedRepository.load / save round-trip", () => {
|
|||||||
expect(await repo.load(FeedId.unchecked("missing"))).toBeNull();
|
expect(await repo.load(FeedId.unchecked("missing"))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Feed native feeds", () => {
|
||||||
|
const nf = (
|
||||||
|
senderKey: string,
|
||||||
|
url: string,
|
||||||
|
type: "rss" | "atom" | "json",
|
||||||
|
) => ({
|
||||||
|
maxBytes: 1_000_000_000,
|
||||||
|
nativeFeeds: { senderKey, feeds: [{ url, type }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores native feeds and raises the flag on ingest", () => {
|
||||||
|
const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
|
||||||
|
feed.ingest(entry(), nf("a@x.com", "https://x.com/rss", "rss"));
|
||||||
|
expect(feed.nativeFeeds()).toEqual([
|
||||||
|
{ url: "https://x.com/rss", type: "rss" },
|
||||||
|
]);
|
||||||
|
expect(feed.hasNativeFeed()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("latest non-empty wins per sender; other senders preserved", () => {
|
||||||
|
const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
|
||||||
|
feed.ingest(
|
||||||
|
entry({ key: "k1" }),
|
||||||
|
nf("a@x.com", "https://x.com/old", "rss"),
|
||||||
|
);
|
||||||
|
feed.ingest(
|
||||||
|
entry({ key: "k2" }),
|
||||||
|
nf("b@y.com", "https://y.com/atom", "atom"),
|
||||||
|
);
|
||||||
|
feed.ingest(
|
||||||
|
entry({ key: "k3" }),
|
||||||
|
nf("a@x.com", "https://x.com/new", "rss"),
|
||||||
|
);
|
||||||
|
expect(feed.nativeFeeds()).toEqual([
|
||||||
|
{ url: "https://x.com/new", type: "rss" },
|
||||||
|
{ url: "https://y.com/atom", type: "atom" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismiss hides the notice but keeps URLs; only a new URL re-raises", () => {
|
||||||
|
const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
|
||||||
|
feed.ingest(
|
||||||
|
entry({ key: "k1" }),
|
||||||
|
nf("a@x.com", "https://x.com/rss", "rss"),
|
||||||
|
);
|
||||||
|
feed.dismissNativeFeed();
|
||||||
|
expect(feed.hasNativeFeed()).toBe(false);
|
||||||
|
expect(feed.nativeFeeds()).toHaveLength(1);
|
||||||
|
feed.ingest(
|
||||||
|
entry({ key: "k2" }),
|
||||||
|
nf("a@x.com", "https://x.com/rss", "rss"),
|
||||||
|
);
|
||||||
|
expect(feed.hasNativeFeed()).toBe(false); // same URL → stays dismissed
|
||||||
|
feed.ingest(
|
||||||
|
entry({ key: "k3" }),
|
||||||
|
nf("a@x.com", "https://x.com/rss2", "rss"),
|
||||||
|
);
|
||||||
|
expect(feed.hasNativeFeed()).toBe(true); // new URL → re-raise
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeEmails leaves native feeds intact", () => {
|
||||||
|
const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
|
||||||
|
feed.ingest(
|
||||||
|
entry({ key: "k1" }),
|
||||||
|
nf("a@x.com", "https://x.com/rss", "rss"),
|
||||||
|
);
|
||||||
|
feed.removeEmails(["k1"]);
|
||||||
|
expect(feed.nativeFeeds()).toEqual([
|
||||||
|
{ url: "https://x.com/rss", type: "rss" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { FeedMetadata, EmailMetadata } from "../types";
|
import { FeedMetadata, EmailMetadata, NativeFeed } from "../types";
|
||||||
import { FeedState } from "./feed-state";
|
import { FeedState } from "./feed-state";
|
||||||
import { FeedId } from "./value-objects/feed-id";
|
import { FeedId } from "./value-objects/feed-id";
|
||||||
|
import { MailboxId } from "./value-objects/mailbox-id";
|
||||||
import { Lifetime } from "./value-objects/lifetime";
|
import { Lifetime } from "./value-objects/lifetime";
|
||||||
import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
|
import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
|
||||||
import { Clock, systemClock } from "./clock";
|
import { Clock, systemClock } from "./clock";
|
||||||
import { FeedEvent } from "./events";
|
import { FeedEvent } from "./events";
|
||||||
|
import { unionNativeFeeds } from "./native-feed";
|
||||||
|
|
||||||
export interface CreateFeedInput {
|
export interface CreateFeedInput {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -12,6 +14,8 @@ export interface CreateFeedInput {
|
|||||||
language: string;
|
language: string;
|
||||||
allowedSenders: string[];
|
allowedSenders: string[];
|
||||||
blockedSenders: string[];
|
blockedSenders: string[];
|
||||||
|
/** When true, render entry titles as `[Sender] Subject` in the feed output. */
|
||||||
|
senderInTitle?: boolean;
|
||||||
/** Raw client-requested lifetime; the application resolves it into a `Lifetime`. */
|
/** Raw client-requested lifetime; the application resolves it into a `Lifetime`. */
|
||||||
lifetimeHours?: number;
|
lifetimeHours?: number;
|
||||||
}
|
}
|
||||||
@@ -22,6 +26,7 @@ export interface UpdateFeedInput {
|
|||||||
language?: string;
|
language?: string;
|
||||||
allowedSenders?: string[];
|
allowedSenders?: string[];
|
||||||
blockedSenders?: string[];
|
blockedSenders?: string[];
|
||||||
|
senderInTitle?: boolean;
|
||||||
lifetimeHours?: number;
|
lifetimeHours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +37,8 @@ export interface UpdateFeedInput {
|
|||||||
* applying any server-side `FEED_TTL_HOURS` override — and hands the VO in.
|
* applying any server-side `FEED_TTL_HOURS` override — and hands the VO in.
|
||||||
*/
|
*/
|
||||||
export interface CreateFeedDeps {
|
export interface CreateFeedDeps {
|
||||||
|
/** The feed's inbound mailbox, minted by the application alongside its FeedId. */
|
||||||
|
mailboxId: MailboxId;
|
||||||
clock?: Clock;
|
clock?: Clock;
|
||||||
/** Effective lifetime, already resolved by the application. */
|
/** Effective lifetime, already resolved by the application. */
|
||||||
lifetime?: Lifetime;
|
lifetime?: Lifetime;
|
||||||
@@ -51,6 +58,8 @@ export interface IngestOptions {
|
|||||||
iconDomain?: string;
|
iconDomain?: string;
|
||||||
/** RFC 8058 one-click unsubscribe link, keyed by the sending newsletter. */
|
/** RFC 8058 one-click unsubscribe link, keyed by the sending newsletter. */
|
||||||
unsub?: { senderKey: string; url: string };
|
unsub?: { senderKey: string; url: string };
|
||||||
|
/** Native syndication feeds the sender advertised, keyed by sender. */
|
||||||
|
nativeFeeds?: { senderKey: string; feeds: NativeFeed[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,7 +91,7 @@ export class Feed {
|
|||||||
static create(
|
static create(
|
||||||
id: FeedId,
|
id: FeedId,
|
||||||
input: CreateFeedInput,
|
input: CreateFeedInput,
|
||||||
deps: CreateFeedDeps = {},
|
deps: CreateFeedDeps,
|
||||||
): Feed {
|
): Feed {
|
||||||
const clock = deps.clock ?? systemClock;
|
const clock = deps.clock ?? systemClock;
|
||||||
const now = clock.now();
|
const now = clock.now();
|
||||||
@@ -91,6 +100,8 @@ export class Feed {
|
|||||||
title: input.title,
|
title: input.title,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
language: input.language,
|
language: input.language,
|
||||||
|
mailboxId: deps.mailboxId.value,
|
||||||
|
senderInTitle: input.senderInTitle,
|
||||||
allowedSenders: input.allowedSenders,
|
allowedSenders: input.allowedSenders,
|
||||||
blockedSenders: input.blockedSenders,
|
blockedSenders: input.blockedSenders,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -130,6 +141,11 @@ export class Feed {
|
|||||||
return this._state.language;
|
return this._state.language;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The inbound mailbox (`noun.noun.NN`) — the feed's email address is `mailboxId@domain`. */
|
||||||
|
get mailboxId(): MailboxId {
|
||||||
|
return MailboxId.unchecked(this._state.mailboxId);
|
||||||
|
}
|
||||||
|
|
||||||
get createdAt(): number {
|
get createdAt(): number {
|
||||||
return this._state.createdAt;
|
return this._state.createdAt;
|
||||||
}
|
}
|
||||||
@@ -146,6 +162,21 @@ export class Feed {
|
|||||||
return this._metadata.iconDomain;
|
return this._metadata.iconDomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while at least one unactioned confirmation email is present. */
|
||||||
|
get pendingConfirmation(): boolean {
|
||||||
|
return this._metadata.pendingConfirmation ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discovered native feeds (Atom/RSS/JSON), union across senders, deduped. */
|
||||||
|
nativeFeeds(): NativeFeed[] {
|
||||||
|
return unionNativeFeeds(this._metadata.nativeFeeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a native feed was discovered and the notice was not dismissed. */
|
||||||
|
hasNativeFeed(): boolean {
|
||||||
|
return this.nativeFeeds().length > 0 && !this._metadata.nativeFeedDismissed;
|
||||||
|
}
|
||||||
|
|
||||||
allowedSenders(): string[] {
|
allowedSenders(): string[] {
|
||||||
return [...this._state.allowedSenders];
|
return [...this._state.allowedSenders];
|
||||||
}
|
}
|
||||||
@@ -203,6 +234,30 @@ export class Feed {
|
|||||||
).decide(senders);
|
).decide(senders);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the email index already contains a duplicate of the incoming
|
||||||
|
* email. Dedup uses `messageId` as the primary key (when both sides have one)
|
||||||
|
* and falls back to `dedupHash` (SHA-256 of normalised subject+content).
|
||||||
|
* Old entries that predate the feature and carry neither field are never
|
||||||
|
* matched — they cannot cause false positives.
|
||||||
|
*/
|
||||||
|
hasDuplicate(messageId?: string, dedupHash?: string): boolean {
|
||||||
|
for (const entry of this._metadata.emails) {
|
||||||
|
if (messageId && entry.messageId && entry.messageId === messageId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!messageId &&
|
||||||
|
dedupHash &&
|
||||||
|
entry.dedupHash &&
|
||||||
|
entry.dedupHash === dedupHash
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add an email to the front of the index, refresh the icon domain and the
|
* 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
|
* per-sender unsubscribe link, then trim the oldest entries back under the
|
||||||
@@ -225,6 +280,23 @@ export class Feed {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.confirmation) {
|
||||||
|
this._metadata.pendingConfirmation = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.nativeFeeds && opts.nativeFeeds.feeds.length > 0) {
|
||||||
|
const known = new Set(this.nativeFeeds().map((f) => f.url));
|
||||||
|
this._metadata.nativeFeeds = {
|
||||||
|
...(this._metadata.nativeFeeds ?? {}),
|
||||||
|
[opts.nativeFeeds.senderKey]: opts.nativeFeeds.feeds,
|
||||||
|
};
|
||||||
|
// Re-raise the notice only when a genuinely new URL appears, so a dismiss
|
||||||
|
// survives the same feed being re-advertised on every subsequent email.
|
||||||
|
if (opts.nativeFeeds.feeds.some((f) => !known.has(f.url))) {
|
||||||
|
this._metadata.nativeFeedDismissed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this._events.push({
|
this._events.push({
|
||||||
type: "EmailIngested",
|
type: "EmailIngested",
|
||||||
feedId: this.id,
|
feedId: this.id,
|
||||||
@@ -262,9 +334,24 @@ export class Feed {
|
|||||||
(target.has(entry.key) ? removed : kept).push(entry);
|
(target.has(entry.key) ? removed : kept).push(entry);
|
||||||
}
|
}
|
||||||
this._metadata.emails = kept;
|
this._metadata.emails = kept;
|
||||||
|
// Lower-only: clear when no confirmation email remains. Never re-raise here,
|
||||||
|
// so an admin "dismiss" survives deletion of unrelated emails.
|
||||||
|
if (!kept.some((e) => e.confirmation)) {
|
||||||
|
this._metadata.pendingConfirmation = false;
|
||||||
|
}
|
||||||
return { removed };
|
return { removed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mark the pending confirmation as handled — "stop reminding me". */
|
||||||
|
dismissConfirmation(): void {
|
||||||
|
this._metadata.pendingConfirmation = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark the native-feed notice as handled — "stop reminding me". */
|
||||||
|
dismissNativeFeed(): void {
|
||||||
|
this._metadata.nativeFeedDismissed = true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single edit path. Apply the patch (only the fields it carries) and
|
* The single edit path. Apply the patch (only the fields it carries) and
|
||||||
* recompute expiry when the application supplies a `Lifetime` — an absent
|
* recompute expiry when the application supplies a `Lifetime` — an absent
|
||||||
@@ -289,6 +376,9 @@ export class Feed {
|
|||||||
this._state.description = patch.description;
|
this._state.description = patch.description;
|
||||||
}
|
}
|
||||||
if (patch.language !== undefined) this._state.language = patch.language;
|
if (patch.language !== undefined) this._state.language = patch.language;
|
||||||
|
if (patch.senderInTitle !== undefined) {
|
||||||
|
this._state.senderInTitle = patch.senderInTitle;
|
||||||
|
}
|
||||||
if (patch.allowedSenders !== undefined) {
|
if (patch.allowedSenders !== undefined) {
|
||||||
this._state.allowedSenders = patch.allowedSenders;
|
this._state.allowedSenders = patch.allowedSenders;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { detectNativeFeeds, unionNativeFeeds } from "./native-feed";
|
||||||
|
|
||||||
|
describe("detectNativeFeeds", () => {
|
||||||
|
it("maps the three canonical MIME types to kinds", () => {
|
||||||
|
expect(
|
||||||
|
detectNativeFeeds([
|
||||||
|
{ href: "https://x.com/atom", type: "application/atom+xml" },
|
||||||
|
{ href: "https://x.com/rss", type: "application/rss+xml" },
|
||||||
|
{ href: "https://x.com/json", type: "application/feed+json" },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ url: "https://x.com/atom", type: "atom" },
|
||||||
|
{ url: "https://x.com/rss", type: "rss" },
|
||||||
|
{ url: "https://x.com/json", type: "json" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unknown MIME types (application/json, text/html)", () => {
|
||||||
|
expect(
|
||||||
|
detectNativeFeeds([
|
||||||
|
{ href: "https://x.com/api", type: "application/json" },
|
||||||
|
{ href: "https://x.com/", type: "text/html" },
|
||||||
|
]),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips MIME parameters and is case-insensitive", () => {
|
||||||
|
expect(
|
||||||
|
detectNativeFeeds([
|
||||||
|
{ href: "https://x.com/f", type: "Application/RSS+XML; charset=utf-8" },
|
||||||
|
]),
|
||||||
|
).toEqual([{ url: "https://x.com/f", type: "rss" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes by URL (first kind wins)", () => {
|
||||||
|
expect(
|
||||||
|
detectNativeFeeds([
|
||||||
|
{ href: "https://x.com/f", type: "application/rss+xml" },
|
||||||
|
{ href: "https://x.com/f", type: "application/atom+xml" },
|
||||||
|
]),
|
||||||
|
).toEqual([{ url: "https://x.com/f", type: "rss" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unionNativeFeeds", () => {
|
||||||
|
it("returns [] for undefined", () => {
|
||||||
|
expect(unionNativeFeeds(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unions across senders, deduping by URL", () => {
|
||||||
|
expect(
|
||||||
|
unionNativeFeeds({
|
||||||
|
"a@x.com": [{ url: "https://x.com/rss", type: "rss" }],
|
||||||
|
"b@y.com": [
|
||||||
|
{ url: "https://x.com/rss", type: "rss" },
|
||||||
|
{ url: "https://y.com/atom", type: "atom" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{ url: "https://x.com/rss", type: "rss" },
|
||||||
|
{ url: "https://y.com/atom", type: "atom" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Pure detection of a newsletter's own syndication feed. No DOM, no I/O — it
|
||||||
|
* receives already-extracted <link> tuples (infra parses the HTML) and decides
|
||||||
|
* which ones are real feeds. This module owns the business knowledge: the strict
|
||||||
|
* set of recognized feed MIME types.
|
||||||
|
*/
|
||||||
|
import { NativeFeed } from "../types";
|
||||||
|
|
||||||
|
// MIME type → feed kind. Strict: only the three canonical syndication types.
|
||||||
|
// `application/json` is deliberately excluded — too broad, captures non-feeds.
|
||||||
|
const MIME_TO_KIND: Record<string, NativeFeed["type"]> = {
|
||||||
|
"application/atom+xml": "atom",
|
||||||
|
"application/rss+xml": "rss",
|
||||||
|
"application/feed+json": "json",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drop MIME parameters ("; charset=…"), trim, lowercase.
|
||||||
|
function normalizeMime(type: string): string {
|
||||||
|
return type.split(";")[0].trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map raw <link> tuples to recognized native feeds, deduped by URL. */
|
||||||
|
export function detectNativeFeeds(
|
||||||
|
links: { href: string; type: string }[],
|
||||||
|
): NativeFeed[] {
|
||||||
|
const out: NativeFeed[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const link of links) {
|
||||||
|
const kind = MIME_TO_KIND[normalizeMime(link.type)];
|
||||||
|
if (!kind) continue;
|
||||||
|
const url = link.href.trim();
|
||||||
|
if (!url || seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
out.push({ url, type: kind });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten per-sender native feeds into one list, deduped by URL (first wins). */
|
||||||
|
export function unionNativeFeeds(
|
||||||
|
bySender: Record<string, NativeFeed[]> | undefined,
|
||||||
|
): NativeFeed[] {
|
||||||
|
if (!bySender) return [];
|
||||||
|
const out: NativeFeed[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const feeds of Object.values(bySender)) {
|
||||||
|
for (const feed of feeds) {
|
||||||
|
if (seen.has(feed.url)) continue;
|
||||||
|
seen.add(feed.url);
|
||||||
|
out.push({ ...feed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -24,4 +24,26 @@ describe("EmailAddress", () => {
|
|||||||
expect(EmailAddress.parse("not an email")).toBeNull();
|
expect(EmailAddress.parse("not an email")).toBeNull();
|
||||||
expect(EmailAddress.parse("")).toBeNull();
|
expect(EmailAddress.parse("")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("derives the sender site base URL from the domain", () => {
|
||||||
|
expect(EmailAddress.parse("News <a@Example.com>")?.siteBaseUrl()).toBe(
|
||||||
|
"https://example.com/",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures the display name verbatim from a display form", () => {
|
||||||
|
const email = EmailAddress.parse("Alice B <Alice@Example.com>")!;
|
||||||
|
expect(email.displayName).toBe("Alice B");
|
||||||
|
expect(email.label()).toBe("Alice B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no display name for a bare address and labels by the address", () => {
|
||||||
|
const email = EmailAddress.parse("Bob@Example.com")!;
|
||||||
|
expect(email.displayName).toBeUndefined();
|
||||||
|
expect(email.label()).toBe("bob@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the address as the label when the display name is empty", () => {
|
||||||
|
expect(EmailAddress.parse("<a@b.com>")?.label()).toBe("a@b.com");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import { Domain } from "./domain";
|
|||||||
/**
|
/**
|
||||||
* A normalised email address. `parse` accepts a bare address (`a@b.com`) or a
|
* 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
|
* display form (`Name <a@b.com>`), lowercasing the local part and normalising
|
||||||
* the domain. Returns null when no plausible address can be found.
|
* the domain. When the input carries a display name it is captured (verbatim,
|
||||||
|
* not normalised — names are case-sensitive). Returns null when no plausible
|
||||||
|
* address can be found.
|
||||||
*/
|
*/
|
||||||
export class EmailAddress {
|
export class EmailAddress {
|
||||||
private constructor(
|
private constructor(
|
||||||
readonly normalized: string,
|
readonly normalized: string,
|
||||||
readonly domain: Domain,
|
readonly domain: Domain,
|
||||||
|
/** The sender's display name from a `Name <addr>` input, if any. */
|
||||||
|
readonly displayName?: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
static parse(raw: string): EmailAddress | null {
|
static parse(raw: string): EmailAddress | null {
|
||||||
@@ -17,7 +21,26 @@ export class EmailAddress {
|
|||||||
const domain = Domain.parse(match[2]);
|
const domain = Domain.parse(match[2]);
|
||||||
if (!domain) return null;
|
if (!domain) return null;
|
||||||
const local = match[1].trim().toLowerCase();
|
const local = match[1].trim().toLowerCase();
|
||||||
return new EmailAddress(`${local}@${domain.value}`, domain);
|
const displayName =
|
||||||
|
raw.match(/^\s*(.+?)\s*<[^>]+>\s*$/)?.[1].trim() || undefined;
|
||||||
|
return new EmailAddress(`${local}@${domain.value}`, domain, displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The best human-readable label for this sender: the display name when the
|
||||||
|
* address came in `Name <addr>` form, else the normalised address.
|
||||||
|
*/
|
||||||
|
label(): string {
|
||||||
|
return this.displayName ?? this.normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort website origin implied by the sender's domain
|
||||||
|
* (e.g. `https://example.com/`). Used to absolutize relative links in the
|
||||||
|
* email body — the sender's site is the only base we can infer.
|
||||||
|
*/
|
||||||
|
siteBaseUrl(): string {
|
||||||
|
return `https://${this.domain.value}/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
toString(): string {
|
toString(): string {
|
||||||
|
|||||||
@@ -1,36 +1,27 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { FeedId } from "./feed-id";
|
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", () => {
|
describe("FeedId.generate", () => {
|
||||||
it("produces the noun.noun.NN format", () => {
|
it("produces an opaque base64url token", () => {
|
||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
expect(FeedId.generate().value).toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
|
expect(FeedId.generate().value).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips through parse from an address", () => {
|
it("is unguessable: 50 ids are all distinct", () => {
|
||||||
const id = FeedId.generate();
|
const ids = new Set(
|
||||||
expect(FeedId.parse(`${id.value}@example.com`)?.value).toBe(id.value);
|
Array.from({ length: 50 }, () => FeedId.generate().value),
|
||||||
|
);
|
||||||
|
expect(ids.size).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not produce the legacy noun.noun.NN format", () => {
|
||||||
|
expect(FeedId.generate().value).not.toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FeedId.unchecked", () => {
|
||||||
|
it("wraps a value without validation", () => {
|
||||||
|
expect(FeedId.unchecked("anything").value).toBe("anything");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,37 +1,41 @@
|
|||||||
import { nouns } from "../../data/nouns";
|
/** Encode bytes as unpadded base64url (URL- and KV-key-safe). */
|
||||||
|
function base64url(bytes: Uint8Array): string {
|
||||||
// Feed IDs are noun1.noun2.XY (two lowercase nouns + a 2-digit suffix).
|
let binary = "";
|
||||||
const FEED_ID_IN_ADDRESS = /^([a-z]+\.[a-z]+\.\d{2})@/i;
|
for (const b of bytes) binary += String.fromCharCode(b);
|
||||||
|
return btoa(binary)
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A feed identifier. `parse` pulls it from the local part of an inbound email
|
* A feed's identity: the KV storage key AND the public read id in `/rss/:feedId`
|
||||||
* address; `generate` mints a fresh one. The original casing is preserved.
|
* etc. It is an opaque, high-entropy random token — unguessable, so a feed's read
|
||||||
|
* URL can be shared without revealing its inbound address (the `MailboxId`, a
|
||||||
|
* separate value that resolves here via the `inbound:` index at reception).
|
||||||
|
*
|
||||||
|
* `generate` mints a fresh token; `unchecked` wraps a route param or stored key
|
||||||
|
* without revalidation (a wrong id simply misses in KV and 404s downstream).
|
||||||
*/
|
*/
|
||||||
export class FeedId {
|
export class FeedId {
|
||||||
private constructor(readonly value: string) {}
|
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
|
* 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
|
* 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
|
* `feeds:list` entry, an `inbound:` index value, or a KV key. The name is
|
||||||
* wrong id is not rejected here, it simply misses in KV and 404s downstream.
|
* deliberately blunt: a wrong id is not rejected here, it simply misses in KV
|
||||||
* Untrusted external input (an inbound address) must go through `parse` instead.
|
* and 404s downstream.
|
||||||
*/
|
*/
|
||||||
static unchecked(value: string): FeedId {
|
static unchecked(value: string): FeedId {
|
||||||
return new FeedId(value);
|
return new FeedId(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mint a fresh, opaque identity (128 bits of entropy → 22 base64url chars). */
|
||||||
static generate(): FeedId {
|
static generate(): FeedId {
|
||||||
const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
|
const bytes = new Uint8Array(16);
|
||||||
const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
|
crypto.getRandomValues(bytes);
|
||||||
const number = Math.floor(Math.random() * 90) + 10;
|
return new FeedId(base64url(bytes));
|
||||||
return new FeedId(`${noun1}.${noun2}.${number}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toString(): string {
|
toString(): string {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { MailboxId } from "./mailbox-id";
|
||||||
|
|
||||||
|
describe("MailboxId.parse", () => {
|
||||||
|
it("extracts the mailbox id from an inbound address", () => {
|
||||||
|
expect(MailboxId.parse("river.castle.42@example.com")?.value).toBe(
|
||||||
|
"river.castle.42",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the original casing of the local part", () => {
|
||||||
|
expect(MailboxId.parse("River.Castle.42@example.com")?.value).toBe(
|
||||||
|
"River.Castle.42",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed mailbox ids", () => {
|
||||||
|
expect(MailboxId.parse("user@example.com")).toBeNull();
|
||||||
|
expect(MailboxId.parse("notanemail")).toBeNull();
|
||||||
|
expect(MailboxId.parse("river.castle.4@example.com")).toBeNull();
|
||||||
|
expect(MailboxId.parse("river.castle.123@example.com")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MailboxId.generate", () => {
|
||||||
|
it("produces the noun.noun.NN format", () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
expect(MailboxId.generate().value).toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through parse from an address", () => {
|
||||||
|
const id = MailboxId.generate();
|
||||||
|
expect(MailboxId.parse(`${id.value}@example.com`)?.value).toBe(id.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MailboxId.unchecked", () => {
|
||||||
|
it("wraps a value without validation", () => {
|
||||||
|
expect(MailboxId.unchecked("anything").value).toBe("anything");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MailboxId.emailAddress", () => {
|
||||||
|
it("builds the full inbound address from the mailbox and a domain", () => {
|
||||||
|
expect(
|
||||||
|
MailboxId.unchecked("river.castle.42").emailAddress("news.app"),
|
||||||
|
).toBe("river.castle.42@news.app");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { nouns } from "../../data/nouns";
|
||||||
|
|
||||||
|
// Inbound mailbox ids are noun1.noun2.XY (two lowercase nouns + a 2-digit suffix).
|
||||||
|
const MAILBOX_IN_ADDRESS = /^([a-z]+\.[a-z]+\.\d{2})@/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A feed's inbound mailbox identifier — the friendly `noun.noun.NN` local part of
|
||||||
|
* the address newsletters are sent to (`<mailboxId>@domain`). It is deliberately
|
||||||
|
* NOT the feed's identity: a `MailboxId` resolves to a `FeedId` through the
|
||||||
|
* `inbound:` index at reception, so the public read URL (the opaque `FeedId`) and
|
||||||
|
* the inbound address stay decoupled.
|
||||||
|
*
|
||||||
|
* `parse` pulls it from an untrusted inbound address (the most untrusted input in
|
||||||
|
* the system); `generate` mints a fresh one. The original casing is preserved.
|
||||||
|
*/
|
||||||
|
export class MailboxId {
|
||||||
|
private constructor(readonly value: string) {}
|
||||||
|
|
||||||
|
/** Extract the mailbox id from an inbound address (`noun.noun.NN@domain`). */
|
||||||
|
static parse(emailAddress: string): MailboxId | null {
|
||||||
|
const match = emailAddress.match(MAILBOX_IN_ADDRESS);
|
||||||
|
return match ? new MailboxId(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a string as a MailboxId WITHOUT revalidating it. The caller asserts the
|
||||||
|
* value originated from our own minting (a stored `mailbox_id`). Untrusted
|
||||||
|
* external input (an inbound address) must go through `parse` instead.
|
||||||
|
*/
|
||||||
|
static unchecked(value: string): MailboxId {
|
||||||
|
return new MailboxId(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static generate(): MailboxId {
|
||||||
|
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 MailboxId(`${noun1}.${noun2}.${number}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full inbound email address (`<mailboxId>@<domain>`) newsletters target. */
|
||||||
|
emailAddress(domain: string): string {
|
||||||
|
return `${this.value}@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import worker from "./index";
|
import worker from "./index";
|
||||||
|
import { APP_VERSION } from "./config/version";
|
||||||
import { createMockEnv } from "./test/setup";
|
import { createMockEnv } from "./test/setup";
|
||||||
|
import { createFeedRecord } from "./application/feed-service";
|
||||||
|
import { FeedRepository } from "./infrastructure/feed-repository";
|
||||||
|
import { FeedId } from "./domain/value-objects/feed-id";
|
||||||
|
import { MailboxId } from "./domain/value-objects/mailbox-id";
|
||||||
import type { Env } from "./types";
|
import type { Env } from "./types";
|
||||||
|
|
||||||
const env = createMockEnv();
|
const env = createMockEnv();
|
||||||
|
|
||||||
|
const noopCtx = {
|
||||||
|
waitUntil: () => {},
|
||||||
|
passThroughOnException: () => {},
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
function req(path: string, init: RequestInit = {}): Request {
|
function req(path: string, init: RequestInit = {}): Request {
|
||||||
return new Request(`https://test.getmynews.app${path}`, init);
|
return new Request(`https://test.getmynews.app${path}`, init);
|
||||||
}
|
}
|
||||||
@@ -54,3 +64,61 @@ describe("CORS middleware", () => {
|
|||||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("scheduled (cron) TTL cleanup", () => {
|
||||||
|
it("drops the inbound mailbox index when an expired feed is purged", async () => {
|
||||||
|
const cronEnv = createMockEnv() as unknown as Env;
|
||||||
|
const { feedId, mailboxId } = await createFeedRecord(cronEnv, {
|
||||||
|
title: "Expiring",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
});
|
||||||
|
const repo = FeedRepository.from(cronEnv);
|
||||||
|
|
||||||
|
// The address resolves to the feed before the cron runs.
|
||||||
|
expect(
|
||||||
|
(await repo.resolveInbound(MailboxId.unchecked(mailboxId)))?.value,
|
||||||
|
).toBe(feedId);
|
||||||
|
|
||||||
|
// Backdate the feed so the cron treats it as expired.
|
||||||
|
const list = (await cronEnv.EMAIL_STORAGE.get("feeds:list", "json")) as {
|
||||||
|
feeds: Array<{ id: string; expires_at?: number; mailbox_id?: string }>;
|
||||||
|
};
|
||||||
|
list.feeds[0].expires_at = Date.now() - 1000;
|
||||||
|
await cronEnv.EMAIL_STORAGE.put("feeds:list", JSON.stringify(list));
|
||||||
|
|
||||||
|
await worker.scheduled({} as ScheduledEvent, cronEnv, noopCtx);
|
||||||
|
|
||||||
|
// The feed is gone AND its inbound address no longer resolves.
|
||||||
|
expect(await repo.getConfig(FeedId.unchecked(feedId))).toBeNull();
|
||||||
|
expect(
|
||||||
|
await repo.resolveInbound(MailboxId.unchecked(mailboxId)),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /health", () => {
|
||||||
|
it("reports status ok and the bundled app version", async () => {
|
||||||
|
const res = await worker.fetch(req("/health"), env as unknown as Env);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { status: string; version: string };
|
||||||
|
expect(body.status).toBe("ok");
|
||||||
|
expect(body.version).toBe(APP_VERSION);
|
||||||
|
expect(body.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /robots.txt", () => {
|
||||||
|
it("returns 200 and disallows the private feed/entry paths", async () => {
|
||||||
|
const res = await worker.fetch(req("/robots.txt"), env as unknown as Env);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("User-agent: *");
|
||||||
|
expect(body).toContain("Disallow: /rss/");
|
||||||
|
expect(body).toContain("Disallow: /atom/");
|
||||||
|
expect(body).toContain("Disallow: /entries/");
|
||||||
|
expect(body).toContain("Disallow: /files/");
|
||||||
|
expect(body).toContain("Disallow: /admin/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+19
-1
@@ -3,6 +3,7 @@ import { cors } from "hono/cors";
|
|||||||
import { handle as handleInbound } from "./routes/inbound";
|
import { handle as handleInbound } from "./routes/inbound";
|
||||||
import { handle as handleRSS } from "./routes/rss";
|
import { handle as handleRSS } from "./routes/rss";
|
||||||
import { handle as handleAtom } from "./routes/atom";
|
import { handle as handleAtom } from "./routes/atom";
|
||||||
|
import { handle as handleJSON } from "./routes/json";
|
||||||
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";
|
||||||
@@ -12,6 +13,7 @@ import { hubRouter } from "./routes/hub";
|
|||||||
import { apiApp } from "./routes/api";
|
import { apiApp } from "./routes/api";
|
||||||
import { handleCloudflareEmail } from "./infrastructure/cloudflare-email";
|
import { handleCloudflareEmail } from "./infrastructure/cloudflare-email";
|
||||||
import { Env } from "./types";
|
import { Env } from "./types";
|
||||||
|
import { APP_VERSION } from "./config/version";
|
||||||
import { logger } from "./infrastructure/logger";
|
import { logger } from "./infrastructure/logger";
|
||||||
import { FeedRepository } from "./infrastructure/feed-repository";
|
import { FeedRepository } from "./infrastructure/feed-repository";
|
||||||
import { purgeExpiredFeeds } from "./application/feed-cleanup";
|
import { purgeExpiredFeeds } from "./application/feed-cleanup";
|
||||||
@@ -116,6 +118,7 @@ app.use(
|
|||||||
const api = new Hono<AppEnv>();
|
const api = new Hono<AppEnv>();
|
||||||
const rss = new Hono<AppEnv>();
|
const rss = new Hono<AppEnv>();
|
||||||
const atom = new Hono<AppEnv>();
|
const atom = new Hono<AppEnv>();
|
||||||
|
const json = new Hono<AppEnv>();
|
||||||
const entries = new Hono<AppEnv>();
|
const entries = new Hono<AppEnv>();
|
||||||
const files = new Hono<AppEnv>();
|
const files = new Hono<AppEnv>();
|
||||||
const admin = new Hono<AppEnv>();
|
const admin = new Hono<AppEnv>();
|
||||||
@@ -151,6 +154,9 @@ rss.get("/:feedId", handleRSS);
|
|||||||
// Atom feed routes (public)
|
// Atom feed routes (public)
|
||||||
atom.get("/:feedId", handleAtom);
|
atom.get("/:feedId", handleAtom);
|
||||||
|
|
||||||
|
// JSON Feed routes (public)
|
||||||
|
json.get("/:feedId", handleJSON);
|
||||||
|
|
||||||
// Email entry HTML view (public)
|
// Email entry HTML view (public)
|
||||||
entries.get("/:feedId/:entryId", handleEntry);
|
entries.get("/:feedId/:entryId", handleEntry);
|
||||||
|
|
||||||
@@ -166,6 +172,7 @@ app.route("/api", api);
|
|||||||
app.route("/api", apiApp);
|
app.route("/api", apiApp);
|
||||||
app.route("/rss", rss);
|
app.route("/rss", rss);
|
||||||
app.route("/atom", atom);
|
app.route("/atom", atom);
|
||||||
|
app.route("/json", json);
|
||||||
app.route("/entries", entries);
|
app.route("/entries", entries);
|
||||||
app.route("/files", files);
|
app.route("/files", files);
|
||||||
app.route("/admin", admin);
|
app.route("/admin", admin);
|
||||||
@@ -179,11 +186,21 @@ app.get("/favicon.ico", handleFavicon); // readers/browsers that hardcode .ico
|
|||||||
app.get("/favicon/:feedId", handleFeedFavicon);
|
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", version: APP_VERSION, timestamp: Date.now() }),
|
||||||
|
);
|
||||||
|
|
||||||
// Public status page (counters + link to admin)
|
// Public status page (counters + link to admin)
|
||||||
app.get("/", handleHome);
|
app.get("/", handleHome);
|
||||||
|
|
||||||
|
// Keep private feeds/emails out of search engines (defense in depth alongside
|
||||||
|
// the X-Robots-Tag headers on the feed/entry/file responses).
|
||||||
|
app.get("/robots.txt", (c) =>
|
||||||
|
c.text(
|
||||||
|
"User-agent: *\nDisallow: /rss/\nDisallow: /atom/\nDisallow: /entries/\nDisallow: /files/\nDisallow: /admin/\n",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Catch-all for 404s
|
// Catch-all for 404s
|
||||||
app.all("*", (c) => c.text("Not Found", 404));
|
app.all("*", (c) => c.text("Not Found", 404));
|
||||||
|
|
||||||
@@ -214,6 +231,7 @@ export default {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (expiredIds.length > 0) {
|
if (expiredIds.length > 0) {
|
||||||
|
// removeFromListBulk also drops each feed's inbound mailbox index.
|
||||||
await repo.removeFromListBulk(expiredIds);
|
await repo.removeFromListBulk(expiredIds);
|
||||||
await bumpCounters(env.EMAIL_STORAGE, {
|
await bumpCounters(env.EMAIL_STORAGE, {
|
||||||
feeds_deleted: expiredIds.length,
|
feeds_deleted: expiredIds.length,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import "../test/setup";
|
import "../test/setup";
|
||||||
import { createMockEnv } from "../test/setup";
|
import { createMockEnv, seedInboundIndex } from "../test/setup";
|
||||||
import { handleCloudflareEmail } from "./cloudflare-email";
|
import { handleCloudflareEmail } from "./cloudflare-email";
|
||||||
|
import { getCounters } from "../application/stats";
|
||||||
|
|
||||||
const VALID_FEED_ID = "apple.mountain.42";
|
const VALID_FEED_ID = "apple.mountain.42";
|
||||||
const DOMAIN = "test.getmynews.app";
|
const DOMAIN = "test.getmynews.app";
|
||||||
@@ -18,7 +19,12 @@ const RAW_EMAIL = [
|
|||||||
].join("\r\n");
|
].join("\r\n");
|
||||||
|
|
||||||
function makeMessage(
|
function makeMessage(
|
||||||
overrides: Partial<{ from: string; to: string; rawText: string }> = {},
|
overrides: Partial<{
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
rawText: string;
|
||||||
|
forward: (rcptTo: string, headers?: Headers) => Promise<void>;
|
||||||
|
}> = {},
|
||||||
): ForwardableEmailMessage {
|
): ForwardableEmailMessage {
|
||||||
const rawText = overrides.rawText ?? RAW_EMAIL;
|
const rawText = overrides.rawText ?? RAW_EMAIL;
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
@@ -36,17 +42,29 @@ function makeMessage(
|
|||||||
headers: new Headers(),
|
headers: new Headers(),
|
||||||
raw: stream,
|
raw: stream,
|
||||||
rawSize: bytes.length,
|
rawSize: bytes.length,
|
||||||
forward: async () => {},
|
forward: overrides.forward ?? (async () => {}),
|
||||||
reply: async () => {},
|
reply: async () => {},
|
||||||
setReject: () => {},
|
setReject: () => {},
|
||||||
} as unknown as ForwardableEmailMessage;
|
} as unknown as ForwardableEmailMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Records every message.forward() call so tests can assert on routing. */
|
||||||
|
function spyForward() {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const forward = async (rcptTo: string) => {
|
||||||
|
calls.push(rcptTo);
|
||||||
|
};
|
||||||
|
return { calls, forward };
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK = "fallback@personal.example";
|
||||||
|
|
||||||
describe("handleCloudflareEmail", () => {
|
describe("handleCloudflareEmail", () => {
|
||||||
let env: ReturnType<typeof createMockEnv>;
|
let env: ReturnType<typeof createMockEnv>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
env = createMockEnv();
|
env = createMockEnv();
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores email in KV when feed exists", async () => {
|
it("stores email in KV when feed exists", async () => {
|
||||||
@@ -123,4 +141,159 @@ describe("handleCloudflareEmail", () => {
|
|||||||
);
|
);
|
||||||
expect(metadata).toBeNull();
|
expect(metadata).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FALLBACK_FORWARD_ADDRESS catch-all fallback", () => {
|
||||||
|
it("forwards to the fallback when the feed does not exist", async () => {
|
||||||
|
const { calls, forward } = spyForward();
|
||||||
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
||||||
|
|
||||||
|
await handleCloudflareEmail(
|
||||||
|
makeMessage({ forward }),
|
||||||
|
env as any,
|
||||||
|
{ waitUntil: () => {} } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls).toEqual([FALLBACK]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards to the fallback when the address is not a feed", async () => {
|
||||||
|
const { calls, forward } = spyForward();
|
||||||
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
||||||
|
|
||||||
|
await handleCloudflareEmail(
|
||||||
|
makeMessage({ to: `not-a-feed@${DOMAIN}`, forward }),
|
||||||
|
env as any,
|
||||||
|
{ waitUntil: () => {} } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls).toEqual([FALLBACK]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT forward an expired feed's mail (no newsletter leak)", async () => {
|
||||||
|
const { calls, forward } = spyForward();
|
||||||
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({ expires_at: Date.now() - 1000 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleCloudflareEmail(
|
||||||
|
makeMessage({ forward }),
|
||||||
|
env as any,
|
||||||
|
{ waitUntil: () => {} } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT forward when the sender is blocked", async () => {
|
||||||
|
const { calls, forward } = spyForward();
|
||||||
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({ allowed_senders: ["other@example.com"] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleCloudflareEmail(
|
||||||
|
makeMessage({ forward }),
|
||||||
|
env as any,
|
||||||
|
{ waitUntil: () => {} } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT forward when the email was ingested", async () => {
|
||||||
|
const { calls, forward } = spyForward();
|
||||||
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleCloudflareEmail(
|
||||||
|
makeMessage({ forward }),
|
||||||
|
env as any,
|
||||||
|
{ waitUntil: () => {} } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT forward when the env var is unset (current drop behavior)", async () => {
|
||||||
|
const { calls, forward } = spyForward();
|
||||||
|
// env.FALLBACK_FORWARD_ADDRESS intentionally left unset.
|
||||||
|
|
||||||
|
await handleCloudflareEmail(
|
||||||
|
makeMessage({ forward }),
|
||||||
|
env as any,
|
||||||
|
{ waitUntil: () => {} } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw when the fallback forward fails (unverified address)", async () => {
|
||||||
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
||||||
|
const forward = async () => {
|
||||||
|
throw new Error("destination address not verified");
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleCloudflareEmail(
|
||||||
|
makeMessage({ forward }),
|
||||||
|
env as any,
|
||||||
|
{ waitUntil: () => {} } as any,
|
||||||
|
),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments the emails_forwarded counter on a successful forward", async () => {
|
||||||
|
const { forward } = spyForward();
|
||||||
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
||||||
|
|
||||||
|
await handleCloudflareEmail(
|
||||||
|
makeMessage({ forward }),
|
||||||
|
env as any,
|
||||||
|
{ waitUntil: () => {} } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||||
|
expect(counters.emails_forwarded).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not increment emails_forwarded when the forward fails", async () => {
|
||||||
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
||||||
|
const forward = async () => {
|
||||||
|
throw new Error("destination address not verified");
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleCloudflareEmail(
|
||||||
|
makeMessage({ forward }),
|
||||||
|
env as any,
|
||||||
|
{ waitUntil: () => {} } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||||
|
expect(counters.emails_forwarded).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not increment emails_forwarded for dropped reasons", async () => {
|
||||||
|
const { forward } = spyForward();
|
||||||
|
env.FALLBACK_FORWARD_ADDRESS = FALLBACK;
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({ expires_at: Date.now() - 1000 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleCloudflareEmail(
|
||||||
|
makeMessage({ forward }),
|
||||||
|
env as any,
|
||||||
|
{ waitUntil: () => {} } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||||
|
expect(counters.emails_forwarded).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import PostalMime from "postal-mime";
|
import PostalMime from "postal-mime";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
import { processEmail, RawAttachment } from "../application/email-processor";
|
import {
|
||||||
|
processEmail,
|
||||||
|
RawAttachment,
|
||||||
|
IngestRejectionReason,
|
||||||
|
} from "../application/email-processor";
|
||||||
|
import { bumpCounters } from "../application/stats";
|
||||||
import { normalizeCid } from "../infrastructure/html-processor";
|
import { normalizeCid } from "../infrastructure/html-processor";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
@@ -51,8 +56,40 @@ export async function handleCloudflareEmail(
|
|||||||
to: message.to,
|
to: message.to,
|
||||||
reason: result.reason,
|
reason: result.reason,
|
||||||
});
|
});
|
||||||
|
await maybeForwardFallback(message, env, result.reason);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error processing Cloudflare email:", error);
|
console.error("Error processing Cloudflare email:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reasons safe to forward to the catch-all fallback: the mail was never a feed's
|
||||||
|
// (wrong address shape, or no such feed). Expired feeds and blocked senders are
|
||||||
|
// dropped so a real newsletter never leaks into the fallback inbox.
|
||||||
|
const FORWARDABLE_REASONS = new Set<IngestRejectionReason>([
|
||||||
|
"invalid_address",
|
||||||
|
"mailbox_unknown",
|
||||||
|
"feed_not_found",
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function maybeForwardFallback(
|
||||||
|
message: ForwardableEmailMessage,
|
||||||
|
env: Env,
|
||||||
|
reason: IngestRejectionReason,
|
||||||
|
): Promise<void> {
|
||||||
|
const fallback = env.FALLBACK_FORWARD_ADDRESS;
|
||||||
|
if (!fallback || !FORWARDABLE_REASONS.has(reason)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await message.forward(fallback);
|
||||||
|
// Counted as a subset of emails_rejected (already bumped in processEmail);
|
||||||
|
// the dropped count is derived as emails_rejected − emails_forwarded.
|
||||||
|
await bumpCounters(env.EMAIL_STORAGE, { emails_forwarded: 1 });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("Fallback forward failed", {
|
||||||
|
to: message.to,
|
||||||
|
fallback,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ describe("CountersRepository", () => {
|
|||||||
feeds_deleted: 0,
|
feeds_deleted: 0,
|
||||||
emails_received: 2,
|
emails_received: 2,
|
||||||
emails_rejected: 0,
|
emails_rejected: 0,
|
||||||
|
emails_forwarded: 0,
|
||||||
|
emails_deduplicated: 0,
|
||||||
unsubscribes_sent: 0,
|
unsubscribes_sent: 0,
|
||||||
});
|
});
|
||||||
expect(await repo.getRaw()).toMatchObject({ emails_received: 2 });
|
expect(await repo.getRaw()).toMatchObject({ emails_received: 2 });
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const mockFeedConfig: FeedConfig = {
|
|||||||
title: "Test Newsletter",
|
title: "Test Newsletter",
|
||||||
description: "A test feed",
|
description: "A test feed",
|
||||||
language: "en",
|
language: "en",
|
||||||
|
mailbox_id: "test.news.42",
|
||||||
created_at: 1700000000000,
|
created_at: 1700000000000,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,14 +147,46 @@ describe("generateRssFeed", () => {
|
|||||||
expect(result).not.toContain("<item>");
|
expect(result).not.toContain("<item>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("feed link points to admin emails page", () => {
|
it("leaves the item title unprefixed by default", () => {
|
||||||
const result = generateRssFeed(
|
const result = generateRssFeed(
|
||||||
mockFeedConfig,
|
mockFeedConfig,
|
||||||
mockEmails,
|
mockEmails,
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
FEED_ID,
|
FEED_ID,
|
||||||
);
|
);
|
||||||
expect(result).toContain(`${BASE_URL}/admin/feeds/${FEED_ID}/emails`);
|
expect(result).toContain("Hello World");
|
||||||
|
expect(result).not.toContain("[Alice]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefixes the item title with the sender when sender_in_title is set", () => {
|
||||||
|
const result = generateRssFeed(
|
||||||
|
{ ...mockFeedConfig, sender_in_title: true },
|
||||||
|
mockEmails,
|
||||||
|
BASE_URL,
|
||||||
|
FEED_ID,
|
||||||
|
);
|
||||||
|
expect(result).toContain("[Alice] Hello World");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the email address when the sender has no display name", () => {
|
||||||
|
const result = generateRssFeed(
|
||||||
|
{ ...mockFeedConfig, sender_in_title: true },
|
||||||
|
[{ ...mockEmails[0], from: "bob@example.com" }],
|
||||||
|
BASE_URL,
|
||||||
|
FEED_ID,
|
||||||
|
);
|
||||||
|
expect(result).toContain("[bob@example.com] Hello World");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("feed link points to the public read URL, never an admin path", () => {
|
||||||
|
const result = generateRssFeed(
|
||||||
|
mockFeedConfig,
|
||||||
|
mockEmails,
|
||||||
|
BASE_URL,
|
||||||
|
FEED_ID,
|
||||||
|
);
|
||||||
|
expect(result).toContain(`<link>${BASE_URL}/rss/${FEED_ID}</link>`);
|
||||||
|
expect(result).not.toContain("/admin/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips html/head/body wrapper from item description", () => {
|
it("strips html/head/body wrapper from item description", () => {
|
||||||
@@ -263,14 +296,15 @@ describe("generateAtomFeed", () => {
|
|||||||
expect(result).not.toContain("<entry>");
|
expect(result).not.toContain("<entry>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("feed link points to admin emails page", () => {
|
it("feed link points to the public read URL, never an admin path", () => {
|
||||||
const result = generateAtomFeed(
|
const result = generateAtomFeed(
|
||||||
mockFeedConfig,
|
mockFeedConfig,
|
||||||
mockEmails,
|
mockEmails,
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
FEED_ID,
|
FEED_ID,
|
||||||
);
|
);
|
||||||
expect(result).toContain(`${BASE_URL}/admin/feeds/${FEED_ID}/emails`);
|
expect(result).toContain(`${BASE_URL}/rss/${FEED_ID}`);
|
||||||
|
expect(result).not.toContain("/admin/");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("strips html/head/body wrapper from entry content", () => {
|
it("strips html/head/body wrapper from entry content", () => {
|
||||||
@@ -313,6 +347,66 @@ describe("generateAtomFeed", () => {
|
|||||||
expect(result).toContain("Bob");
|
expect(result).toContain("Bob");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the subject as plain text in <title> (strips tags, decodes entities)", () => {
|
||||||
|
const emailWithHtmlSubject: EmailData = {
|
||||||
|
...mockEmails[0],
|
||||||
|
subject: "<b>Sale</b> Tom & Jerry",
|
||||||
|
};
|
||||||
|
const result = generateAtomFeed(
|
||||||
|
mockFeedConfig,
|
||||||
|
[emailWithHtmlSubject],
|
||||||
|
BASE_URL,
|
||||||
|
FEED_ID,
|
||||||
|
);
|
||||||
|
// Tags are stripped and entities decoded; markup must not survive.
|
||||||
|
expect(result).toContain("Sale Tom & Jerry");
|
||||||
|
expect(result).not.toContain("<b>Sale</b>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips XML-illegal control characters from the output", () => {
|
||||||
|
const emailWithControlChar: EmailData = {
|
||||||
|
...mockEmails[0],
|
||||||
|
subject: "Bad\x00\x1Fchar",
|
||||||
|
content: "<p>body\x0Bhere</p>",
|
||||||
|
};
|
||||||
|
const result = generateAtomFeed(
|
||||||
|
mockFeedConfig,
|
||||||
|
[emailWithControlChar],
|
||||||
|
BASE_URL,
|
||||||
|
FEED_ID,
|
||||||
|
);
|
||||||
|
expect(result).not.toMatch(/[\x00\x0B\x1F]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves emoji (surrogate pairs) in the output", () => {
|
||||||
|
const emailWithEmoji: EmailData = {
|
||||||
|
...mockEmails[0],
|
||||||
|
subject: "Launch 🚀 today",
|
||||||
|
};
|
||||||
|
const result = generateAtomFeed(
|
||||||
|
mockFeedConfig,
|
||||||
|
[emailWithEmoji],
|
||||||
|
BASE_URL,
|
||||||
|
FEED_ID,
|
||||||
|
);
|
||||||
|
expect(result).toContain("🚀");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("absolutizes relative content URLs against the sender domain", () => {
|
||||||
|
const emailWithRelative: EmailData = {
|
||||||
|
...mockEmails[0],
|
||||||
|
from: "News <news@acme.com>",
|
||||||
|
content: '<body><a href="/article">read</a></body>',
|
||||||
|
};
|
||||||
|
const result = generateAtomFeed(
|
||||||
|
mockFeedConfig,
|
||||||
|
[emailWithRelative],
|
||||||
|
BASE_URL,
|
||||||
|
FEED_ID,
|
||||||
|
);
|
||||||
|
expect(result).toContain("https://acme.com/article");
|
||||||
|
});
|
||||||
|
|
||||||
it("includes enclosure link for email with attachment in Atom feed", () => {
|
it("includes enclosure link for email with attachment in Atom feed", () => {
|
||||||
const result = generateAtomFeed(
|
const result = generateAtomFeed(
|
||||||
mockFeedConfig,
|
mockFeedConfig,
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { Feed } from "feed";
|
import { Feed } from "feed";
|
||||||
import { FeedConfig, EmailData } from "../types";
|
import { FeedConfig, EmailData } from "../types";
|
||||||
import { processEmailContent } from "./html-processor";
|
import { processEmailContent, htmlToText } from "./html-processor";
|
||||||
|
import { EmailAddress } from "../domain/value-objects/email-address";
|
||||||
|
import { entryPath } from "./urls";
|
||||||
|
|
||||||
export { processEmailContent as extractBodyContent };
|
export { processEmailContent as extractBodyContent };
|
||||||
|
|
||||||
function parseFromAddress(from: string): { name: string; email?: string } {
|
// XML 1.0 valid chars: #x9 #xA #xD #x20-#xD7FF #xE000-#xFFFD #x10000-#x10FFFF.
|
||||||
const match = from.match(/^(.*?)\s*<([^>]+)>\s*$/);
|
// A single illegal codepoint fails the whole feed parse in strict readers, so
|
||||||
if (match) {
|
// strip the complement before returning. The `u` flag iterates by code point, so
|
||||||
return { name: match[1].trim() || match[2], email: match[2].trim() };
|
// valid surrogate pairs (emoji, …) survive while lone surrogates are removed.
|
||||||
}
|
function stripInvalidXmlChars(xml: string): string {
|
||||||
const emailOnly = from.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
|
return xml.replace(/[^\x09\x0A\x0D\x20--�\u{10000}-\u{10FFFF}]/gu, "");
|
||||||
if (emailOnly) {
|
|
||||||
return { email: from.trim(), name: from.trim() };
|
|
||||||
}
|
|
||||||
return { name: from.trim() };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFeed(
|
function buildFeed(
|
||||||
@@ -21,7 +19,7 @@ function buildFeed(
|
|||||||
emails: EmailData[],
|
emails: EmailData[],
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
feedId: string,
|
feedId: string,
|
||||||
selfUrl?: { rss?: string; atom?: string },
|
selfUrl?: { rss?: string; atom?: string; json?: string },
|
||||||
): Feed {
|
): Feed {
|
||||||
const iconUrl = `${baseUrl}/favicon/${feedId}`;
|
const iconUrl = `${baseUrl}/favicon/${feedId}`;
|
||||||
const feed = new Feed({
|
const feed = new Feed({
|
||||||
@@ -34,8 +32,9 @@ function buildFeed(
|
|||||||
// 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}`,
|
||||||
// Link points to the admin emails page — the "website" this feed represents.
|
// Public "website" for this feed: its own read URL (never the inbound address
|
||||||
link: `${baseUrl}/admin/feeds/${feedId}/emails`,
|
// or an auth-gated admin path, so the feed output leaks neither).
|
||||||
|
link: `${baseUrl}/rss/${feedId}`,
|
||||||
language: feedConfig.language,
|
language: feedConfig.language,
|
||||||
updated: new Date(),
|
updated: new Date(),
|
||||||
generator: "kill-the-news",
|
generator: "kill-the-news",
|
||||||
@@ -43,6 +42,7 @@ function buildFeed(
|
|||||||
feedLinks: {
|
feedLinks: {
|
||||||
rss: selfUrl?.rss ?? `${baseUrl}/rss/${feedId}`,
|
rss: selfUrl?.rss ?? `${baseUrl}/rss/${feedId}`,
|
||||||
atom: selfUrl?.atom ?? `${baseUrl}/atom/${feedId}`,
|
atom: selfUrl?.atom ?? `${baseUrl}/atom/${feedId}`,
|
||||||
|
json: selfUrl?.json ?? `${baseUrl}/json/${feedId}`,
|
||||||
},
|
},
|
||||||
author: feedConfig.author
|
author: feedConfig.author
|
||||||
? {
|
? {
|
||||||
@@ -53,21 +53,31 @@ function buildFeed(
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const email of emails) {
|
for (const email of emails) {
|
||||||
const entryUrl = `${baseUrl}/entries/${feedId}/${email.receivedAt}`;
|
const entryUrl = `${baseUrl}${entryPath(feedId, email.receivedAt)}`;
|
||||||
// Inline images are rendered in the body, not surfaced as an enclosure.
|
// Inline images are rendered in the body, not surfaced as an enclosure.
|
||||||
const firstAttachment = email.attachments?.find((a) => !a.inline);
|
const firstAttachment = email.attachments?.find((a) => !a.inline);
|
||||||
|
const sender = EmailAddress.parse(email.from);
|
||||||
|
const senderLabel = sender?.label() ?? email.from.trim();
|
||||||
const bodyContent = processEmailContent(
|
const bodyContent = processEmailContent(
|
||||||
email.content,
|
email.content,
|
||||||
email.attachments,
|
email.attachments,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
sender?.siteBaseUrl() ?? "",
|
||||||
);
|
);
|
||||||
|
const subject = htmlToText(email.subject);
|
||||||
feed.addItem({
|
feed.addItem({
|
||||||
title: email.subject,
|
title: feedConfig.sender_in_title
|
||||||
|
? `[${senderLabel}] ${subject}`
|
||||||
|
: subject,
|
||||||
id: entryUrl,
|
id: entryUrl,
|
||||||
link: entryUrl,
|
link: entryUrl,
|
||||||
description: bodyContent,
|
description: bodyContent,
|
||||||
content: bodyContent,
|
content: bodyContent,
|
||||||
author: [parseFromAddress(email.from)],
|
author: [
|
||||||
|
sender
|
||||||
|
? { name: senderLabel, email: sender.normalized }
|
||||||
|
: { name: senderLabel },
|
||||||
|
],
|
||||||
date: new Date(email.receivedAt),
|
date: new Date(email.receivedAt),
|
||||||
enclosure: firstAttachment
|
enclosure: firstAttachment
|
||||||
? {
|
? {
|
||||||
@@ -89,13 +99,15 @@ export function generateRssFeed(
|
|||||||
feedId: string,
|
feedId: string,
|
||||||
selfUrl?: string,
|
selfUrl?: string,
|
||||||
): string {
|
): string {
|
||||||
return buildFeed(
|
return stripInvalidXmlChars(
|
||||||
|
buildFeed(
|
||||||
feedConfig,
|
feedConfig,
|
||||||
emails,
|
emails,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
feedId,
|
feedId,
|
||||||
selfUrl ? { rss: selfUrl } : undefined,
|
selfUrl ? { rss: selfUrl } : undefined,
|
||||||
).rss2();
|
).rss2(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateAtomFeed(
|
export function generateAtomFeed(
|
||||||
@@ -104,12 +116,30 @@ export function generateAtomFeed(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
feedId: string,
|
feedId: string,
|
||||||
selfUrl?: string,
|
selfUrl?: string,
|
||||||
|
): string {
|
||||||
|
return stripInvalidXmlChars(
|
||||||
|
buildFeed(
|
||||||
|
feedConfig,
|
||||||
|
emails,
|
||||||
|
baseUrl,
|
||||||
|
feedId,
|
||||||
|
selfUrl ? { atom: selfUrl } : undefined,
|
||||||
|
).atom1(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateJsonFeed(
|
||||||
|
feedConfig: FeedConfig,
|
||||||
|
emails: EmailData[],
|
||||||
|
baseUrl: string,
|
||||||
|
feedId: string,
|
||||||
|
selfUrl?: string,
|
||||||
): string {
|
): string {
|
||||||
return buildFeed(
|
return buildFeed(
|
||||||
feedConfig,
|
feedConfig,
|
||||||
emails,
|
emails,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
feedId,
|
feedId,
|
||||||
selfUrl ? { atom: selfUrl } : undefined,
|
selfUrl ? { json: selfUrl } : undefined,
|
||||||
).atom1();
|
).json1();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const fullConfig: FeedConfig = {
|
|||||||
title: "News",
|
title: "News",
|
||||||
description: "desc",
|
description: "desc",
|
||||||
language: "en",
|
language: "en",
|
||||||
|
mailbox_id: "a.b.42",
|
||||||
author: "Jane",
|
author: "Jane",
|
||||||
allowed_senders: ["a@x.com"],
|
allowed_senders: ["a@x.com"],
|
||||||
blocked_senders: ["b@y.com"],
|
blocked_senders: ["b@y.com"],
|
||||||
@@ -24,6 +25,7 @@ describe("feed-mapper", () => {
|
|||||||
const state = fromConfigDTO({
|
const state = fromConfigDTO({
|
||||||
title: "T",
|
title: "T",
|
||||||
language: "en",
|
language: "en",
|
||||||
|
mailbox_id: "t.t.42",
|
||||||
created_at: 1,
|
created_at: 1,
|
||||||
});
|
});
|
||||||
expect(state.allowedSenders).toEqual([]);
|
expect(state.allowedSenders).toEqual([]);
|
||||||
@@ -39,7 +41,21 @@ describe("feed-mapper", () => {
|
|||||||
id: "a.b.42",
|
id: "a.b.42",
|
||||||
title: "News",
|
title: "News",
|
||||||
description: "desc",
|
description: "desc",
|
||||||
|
mailbox_id: "a.b.42",
|
||||||
expires_at: 3000,
|
expires_at: 3000,
|
||||||
|
pendingConfirmation: false,
|
||||||
|
hasNativeFeed: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("projects hasNativeFeed when passed", () => {
|
||||||
|
const item = toListItemDTO(
|
||||||
|
FeedId.unchecked("a.b.42"),
|
||||||
|
fromConfigDTO(fullConfig),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(item.pendingConfirmation).toBe(true);
|
||||||
|
expect(item.hasNativeFeed).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export function fromConfigDTO(dto: FeedConfig): FeedState {
|
|||||||
title: dto.title,
|
title: dto.title,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
language: dto.language,
|
language: dto.language,
|
||||||
|
mailboxId: dto.mailbox_id,
|
||||||
author: dto.author,
|
author: dto.author,
|
||||||
|
senderInTitle: dto.sender_in_title,
|
||||||
allowedSenders: dto.allowed_senders ?? [],
|
allowedSenders: dto.allowed_senders ?? [],
|
||||||
blockedSenders: dto.blocked_senders ?? [],
|
blockedSenders: dto.blocked_senders ?? [],
|
||||||
createdAt: dto.created_at,
|
createdAt: dto.created_at,
|
||||||
@@ -31,7 +33,9 @@ export function toConfigDTO(state: FeedState): FeedConfig {
|
|||||||
title: state.title,
|
title: state.title,
|
||||||
description: state.description,
|
description: state.description,
|
||||||
language: state.language,
|
language: state.language,
|
||||||
|
mailbox_id: state.mailboxId,
|
||||||
author: state.author,
|
author: state.author,
|
||||||
|
sender_in_title: state.senderInTitle,
|
||||||
allowed_senders: state.allowedSenders,
|
allowed_senders: state.allowedSenders,
|
||||||
blocked_senders: state.blockedSenders,
|
blocked_senders: state.blockedSenders,
|
||||||
created_at: state.createdAt,
|
created_at: state.createdAt,
|
||||||
@@ -41,11 +45,19 @@ export function toConfigDTO(state: FeedState): FeedConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Domain state → the projection cached in the global `feeds:list` registry. */
|
/** Domain state → the projection cached in the global `feeds:list` registry. */
|
||||||
export function toListItemDTO(id: FeedId, state: FeedState): FeedListItem {
|
export function toListItemDTO(
|
||||||
|
id: FeedId,
|
||||||
|
state: FeedState,
|
||||||
|
pendingConfirmation = false,
|
||||||
|
hasNativeFeed = false,
|
||||||
|
): FeedListItem {
|
||||||
return {
|
return {
|
||||||
id: id.value,
|
id: id.value,
|
||||||
title: state.title,
|
title: state.title,
|
||||||
description: state.description,
|
description: state.description,
|
||||||
|
mailbox_id: state.mailboxId,
|
||||||
expires_at: state.expiresAt,
|
expires_at: state.expiresAt,
|
||||||
|
pendingConfirmation,
|
||||||
|
hasNativeFeed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createMockEnv } from "../test/setup";
|
|||||||
import { FeedRepository } from "./feed-repository";
|
import { FeedRepository } from "./feed-repository";
|
||||||
import { Feed } from "../domain/feed.aggregate";
|
import { Feed } from "../domain/feed.aggregate";
|
||||||
import { FeedId } from "../domain/value-objects/feed-id";
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||||
import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
|
import type { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
|
||||||
|
|
||||||
const mockEnv = () => createMockEnv() as unknown as Env;
|
const mockEnv = () => createMockEnv() as unknown as Env;
|
||||||
@@ -11,6 +12,7 @@ const fid = (value: string) => FeedId.unchecked(value);
|
|||||||
const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({
|
const sampleConfig = (overrides: Partial<FeedConfig> = {}): FeedConfig => ({
|
||||||
title: "Test Feed",
|
title: "Test Feed",
|
||||||
language: "en",
|
language: "en",
|
||||||
|
mailbox_id: "test.feed.42",
|
||||||
created_at: 1000,
|
created_at: 1000,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
@@ -46,6 +48,44 @@ describe("FeedRepository key schema", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FeedRepository inbound index", () => {
|
||||||
|
const mbox = (v: string) => MailboxId.unchecked(v);
|
||||||
|
|
||||||
|
it("resolves a mailbox to its feed id and back to null after delete", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
expect(await repo.resolveInbound(mbox("river.castle.42"))).toBeNull();
|
||||||
|
|
||||||
|
await repo.putInboundIndex(mbox("river.castle.42"), fid("opaque-id-1"));
|
||||||
|
expect((await repo.resolveInbound(mbox("river.castle.42")))?.value).toBe(
|
||||||
|
"opaque-id-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
await repo.deleteInboundIndex(mbox("river.castle.42"));
|
||||||
|
expect(await repo.resolveInbound(mbox("river.castle.42"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("save() writes the inbound index from the aggregate's mailbox", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
await repo.save(
|
||||||
|
Feed.reconstitute(
|
||||||
|
fid("opaque-id-2"),
|
||||||
|
{
|
||||||
|
title: "T",
|
||||||
|
language: "en",
|
||||||
|
mailboxId: "lake.tower.77",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
createdAt: 1000,
|
||||||
|
},
|
||||||
|
{ emails: [] },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect((await repo.resolveInbound(mbox("lake.tower.77")))?.value).toBe(
|
||||||
|
"opaque-id-2",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("FeedRepository config & metadata", () => {
|
describe("FeedRepository config & metadata", () => {
|
||||||
it("round-trips and deletes a feed config", async () => {
|
it("round-trips and deletes a feed config", async () => {
|
||||||
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
@@ -106,6 +146,7 @@ describe("FeedRepository feed list", () => {
|
|||||||
{
|
{
|
||||||
title,
|
title,
|
||||||
language: "en",
|
language: "en",
|
||||||
|
mailboxId: `${id}.mbox`,
|
||||||
allowedSenders: [],
|
allowedSenders: [],
|
||||||
blockedSenders: [],
|
blockedSenders: [],
|
||||||
createdAt: 1000,
|
createdAt: 1000,
|
||||||
@@ -153,4 +194,88 @@ describe("FeedRepository feed list", () => {
|
|||||||
expect(removed.sort()).toEqual(["a.b.42", "e.f.10"]);
|
expect(removed.sort()).toEqual(["a.b.42", "e.f.10"]);
|
||||||
expect((await repo.listFeeds()).map((f) => f.id)).toEqual(["c.d.99"]);
|
expect((await repo.listFeeds()).map((f) => f.id)).toEqual(["c.d.99"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops each removed feed's inbound index (symmetric with save)", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
const mbox = (v: string) => MailboxId.unchecked(v);
|
||||||
|
await repo.save(feedWith("a.b.42", "One"));
|
||||||
|
await repo.save(feedWith("c.d.99", "Two"));
|
||||||
|
|
||||||
|
// Both addresses resolve before removal.
|
||||||
|
expect(await repo.resolveInbound(mbox("a.b.42.mbox"))).not.toBeNull();
|
||||||
|
expect(await repo.resolveInbound(mbox("c.d.99.mbox"))).not.toBeNull();
|
||||||
|
|
||||||
|
await repo.removeFromListBulk(["a.b.42"]);
|
||||||
|
|
||||||
|
// The removed feed's address stops resolving; the survivor's still does.
|
||||||
|
expect(await repo.resolveInbound(mbox("a.b.42.mbox"))).toBeNull();
|
||||||
|
expect((await repo.resolveInbound(mbox("c.d.99.mbox")))?.value).toBe(
|
||||||
|
"c.d.99",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FeedRepository pendingConfirmation projection", () => {
|
||||||
|
function makeFeed(): Feed {
|
||||||
|
return Feed.create(
|
||||||
|
FeedId.generate(),
|
||||||
|
{
|
||||||
|
title: "T",
|
||||||
|
description: "",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId: MailboxId.unchecked("alpha.beta.11") },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("saveMetadata projects pendingConfirmation into feeds:list", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
const feed = makeFeed();
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
feed.ingest(
|
||||||
|
{
|
||||||
|
key: "k1",
|
||||||
|
subject: "s",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
size: 10,
|
||||||
|
confirmation: { links: ["https://x/confirm"] },
|
||||||
|
},
|
||||||
|
{ maxBytes: 1_000_000 },
|
||||||
|
);
|
||||||
|
await repo.saveMetadata(feed);
|
||||||
|
|
||||||
|
const list = await repo.listFeeds();
|
||||||
|
const entry = list.find((f) => f.id === feed.id.value);
|
||||||
|
expect(entry?.pendingConfirmation).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saveMetadata clears the projected flag after dismiss", async () => {
|
||||||
|
const repo = new FeedRepository(mockEnv().EMAIL_STORAGE);
|
||||||
|
const feed = makeFeed();
|
||||||
|
feed.ingest(
|
||||||
|
{
|
||||||
|
key: "k1",
|
||||||
|
subject: "s",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
size: 10,
|
||||||
|
confirmation: { links: ["https://x/confirm"] },
|
||||||
|
},
|
||||||
|
{ maxBytes: 1_000_000 },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
expect(
|
||||||
|
(await repo.listFeeds()).find((f) => f.id === feed.id.value)
|
||||||
|
?.pendingConfirmation,
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
feed.dismissConfirmation();
|
||||||
|
await repo.saveMetadata(feed);
|
||||||
|
expect(
|
||||||
|
(await repo.listFeeds()).find((f) => f.id === feed.id.value)
|
||||||
|
?.pendingConfirmation,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { FEEDS_LIST_KEY } from "../config/constants";
|
|||||||
import { feedKeys } from "../domain/feed-keys";
|
import { feedKeys } from "../domain/feed-keys";
|
||||||
import { Feed } from "../domain/feed.aggregate";
|
import { Feed } from "../domain/feed.aggregate";
|
||||||
import { FeedId } from "../domain/value-objects/feed-id";
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||||
import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper";
|
import { fromConfigDTO, toConfigDTO, toListItemDTO } from "./feed-mapper";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
@@ -86,17 +87,36 @@ export class FeedRepository {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.putConfig(feed.id, toConfigDTO(feed.state())),
|
this.putConfig(feed.id, toConfigDTO(feed.state())),
|
||||||
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
|
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
|
||||||
this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
|
this.upsertListEntry(
|
||||||
|
toListItemDTO(
|
||||||
|
feed.id,
|
||||||
|
feed.state(),
|
||||||
|
feed.pendingConfirmation,
|
||||||
|
feed.hasNativeFeed(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
this.putInboundIndex(feed.mailboxId, feed.id),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist only the email index. Used by the ingest/delete paths where config
|
* 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
|
* is unchanged — avoids a redundant config write on the hot path. Also
|
||||||
* projection (title/description/expiry) is untouched, so it is not rewritten.
|
* refreshes the `feeds:list` entry's `pendingConfirmation` projection so the
|
||||||
|
* dashboard reflects the latest flag state with a single subsequent KV read.
|
||||||
*/
|
*/
|
||||||
async saveMetadata(feed: Feed): Promise<void> {
|
async saveMetadata(feed: Feed): Promise<void> {
|
||||||
await this.putMetadata(feed.id, feed.toMetadataSnapshot());
|
await Promise.all([
|
||||||
|
this.putMetadata(feed.id, feed.toMetadataSnapshot()),
|
||||||
|
this.upsertListEntry(
|
||||||
|
toListItemDTO(
|
||||||
|
feed.id,
|
||||||
|
feed.state(),
|
||||||
|
feed.pendingConfirmation,
|
||||||
|
feed.hasNativeFeed(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,10 +127,39 @@ export class FeedRepository {
|
|||||||
async saveConfig(feed: Feed): Promise<void> {
|
async saveConfig(feed: Feed): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.putConfig(feed.id, toConfigDTO(feed.state())),
|
this.putConfig(feed.id, toConfigDTO(feed.state())),
|
||||||
this.upsertListEntry(toListItemDTO(feed.id, feed.state())),
|
this.upsertListEntry(
|
||||||
|
toListItemDTO(
|
||||||
|
feed.id,
|
||||||
|
feed.state(),
|
||||||
|
feed.pendingConfirmation,
|
||||||
|
feed.hasNativeFeed(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
this.putInboundIndex(feed.mailboxId, feed.id),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inbound mailbox index ─────────────────────────────────────────────────
|
||||||
|
// Secondary index mapping the friendly inbound address (`noun.noun.NN`) to the
|
||||||
|
// feed's opaque id. Resolved only at reception (the write edge), so the public
|
||||||
|
// read id and the inbound address stay decoupled.
|
||||||
|
|
||||||
|
/** Resolve an inbound mailbox to its feed id, or null when no feed claims it. */
|
||||||
|
async resolveInbound(mailboxId: MailboxId): Promise<FeedId | null> {
|
||||||
|
const feedId = await this.kv.get(feedKeys.inbound(mailboxId.value), {
|
||||||
|
type: "text",
|
||||||
|
});
|
||||||
|
return feedId ? FeedId.unchecked(feedId) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async putInboundIndex(mailboxId: MailboxId, feedId: FeedId): Promise<void> {
|
||||||
|
await this.kv.put(feedKeys.inbound(mailboxId.value), feedId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInboundIndex(mailboxId: MailboxId): Promise<void> {
|
||||||
|
await this.kv.delete(feedKeys.inbound(mailboxId.value));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Feed config ───────────────────────────────────────────────────────────
|
// ── Feed config ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async getConfig(feedId: FeedId): Promise<FeedConfig | null> {
|
async getConfig(feedId: FeedId): Promise<FeedConfig | null> {
|
||||||
@@ -209,11 +258,13 @@ export class FeedRepository {
|
|||||||
if (toRemove.size === 0) return [];
|
if (toRemove.size === 0) return [];
|
||||||
|
|
||||||
const removed: string[] = [];
|
const removed: string[] = [];
|
||||||
|
const droppedMailboxes: string[] = [];
|
||||||
const nextFeeds: FeedListItem[] = [];
|
const nextFeeds: FeedListItem[] = [];
|
||||||
|
|
||||||
for (const feed of feedList.feeds) {
|
for (const feed of feedList.feeds) {
|
||||||
if (toRemove.has(feed.id)) {
|
if (toRemove.has(feed.id)) {
|
||||||
removed.push(feed.id);
|
removed.push(feed.id);
|
||||||
|
if (feed.mailbox_id) droppedMailboxes.push(feed.mailbox_id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
nextFeeds.push(feed);
|
nextFeeds.push(feed);
|
||||||
@@ -223,6 +274,17 @@ export class FeedRepository {
|
|||||||
|
|
||||||
feedList.feeds = nextFeeds;
|
feedList.feeds = nextFeeds;
|
||||||
await this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
|
await this.kv.put(FEEDS_LIST_KEY, JSON.stringify(feedList));
|
||||||
|
|
||||||
|
// Drop each removed feed's inbound index — symmetric with save() writing
|
||||||
|
// it. The index lives outside the feed:<id>: prefix the key purge sweeps,
|
||||||
|
// so a deleted feed's address would keep resolving if left behind. The
|
||||||
|
// mailbox is cached on the list item we just removed.
|
||||||
|
await Promise.all(
|
||||||
|
droppedMailboxes.map((mailbox) =>
|
||||||
|
this.deleteInboundIndex(MailboxId.unchecked(mailbox)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return removed;
|
return removed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error removing feeds from list", { error: String(error) });
|
logger.error("Error removing feeds from list", { error: String(error) });
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export function ingestResultToResponse(result: IngestResult): Response {
|
|||||||
switch (result.reason) {
|
switch (result.reason) {
|
||||||
case "invalid_address":
|
case "invalid_address":
|
||||||
return new Response("Invalid email address format", { status: 400 });
|
return new Response("Invalid email address format", { status: 400 });
|
||||||
|
case "mailbox_unknown":
|
||||||
|
return new Response("No feed for this address", { status: 404 });
|
||||||
case "feed_not_found":
|
case "feed_not_found":
|
||||||
return new Response("Feed does not exist", { status: 404 });
|
return new Response("Feed does not exist", { status: 404 });
|
||||||
case "feed_expired":
|
case "feed_expired":
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { processEmailContent, extractInlineCids } from "./html-processor";
|
import {
|
||||||
|
processEmailContent,
|
||||||
|
extractInlineCids,
|
||||||
|
htmlToText,
|
||||||
|
extractLinks,
|
||||||
|
extractFeedLinks,
|
||||||
|
} from "./html-processor";
|
||||||
import type { AttachmentData } from "../types";
|
import type { AttachmentData } from "../types";
|
||||||
|
|
||||||
describe("processEmailContent — body extraction", () => {
|
describe("processEmailContent — body extraction", () => {
|
||||||
@@ -197,6 +203,105 @@ describe("processEmailContent — inline cid: rewriting", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("processEmailContent — lazy image promotion", () => {
|
||||||
|
it("promotes data-src to src when src is missing", () => {
|
||||||
|
const html = '<body><img data-src="https://x.com/a.png"/></body>';
|
||||||
|
const result = processEmailContent(html);
|
||||||
|
expect(result).toContain('src="https://x.com/a.png"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes data-src over a data: placeholder src", () => {
|
||||||
|
const html =
|
||||||
|
'<body><img src="data:image/gif;base64,AAAA" data-src="https://x.com/a.png"/></body>';
|
||||||
|
const result = processEmailContent(html);
|
||||||
|
expect(result).toContain('src="https://x.com/a.png"');
|
||||||
|
expect(result).not.toContain("data:image/gif");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clobber a real src with data-src", () => {
|
||||||
|
const html =
|
||||||
|
'<body><img src="https://real.com/a.png" data-src="https://lazy.com/b.png"/></body>';
|
||||||
|
const result = processEmailContent(html);
|
||||||
|
expect(result).toContain('src="https://real.com/a.png"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes data-srcset when srcset is absent", () => {
|
||||||
|
const html = '<body><img data-srcset="https://x.com/a.png 2x"/></body>';
|
||||||
|
const result = processEmailContent(html);
|
||||||
|
expect(result).toContain('srcset="https://x.com/a.png 2x"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips loading=lazy", () => {
|
||||||
|
const html = '<body><img src="https://x.com/a.png" loading="lazy"/></body>';
|
||||||
|
const result = processEmailContent(html);
|
||||||
|
expect(result).not.toContain("loading");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("processEmailContent — relative URL absolutization", () => {
|
||||||
|
const base = "https://news.example.com/";
|
||||||
|
|
||||||
|
it("absolutizes a root-relative href against the sender base", () => {
|
||||||
|
const html = '<body><a href="/path">link</a></body>';
|
||||||
|
const result = processEmailContent(html, undefined, "", base);
|
||||||
|
expect(result).toContain('href="https://news.example.com/path"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("absolutizes a relative img src against the sender base", () => {
|
||||||
|
const html = '<body><img src="img/a.png"/></body>';
|
||||||
|
const result = processEmailContent(html, undefined, "", base);
|
||||||
|
expect(result).toContain('src="https://news.example.com/img/a.png"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves protocol-relative URLs using https", () => {
|
||||||
|
const html = '<body><img src="//cdn.example.com/a.png"/></body>';
|
||||||
|
const result = processEmailContent(html, undefined, "", base);
|
||||||
|
expect(result).toContain('src="https://cdn.example.com/a.png"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves absolute URLs unchanged", () => {
|
||||||
|
const html = '<body><a href="https://other.com/x">l</a></body>';
|
||||||
|
const result = processEmailContent(html, undefined, "", base);
|
||||||
|
expect(result).toContain('href="https://other.com/x"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not touch relative URLs when no sender base is given", () => {
|
||||||
|
const html = '<body><a href="/path">link</a></body>';
|
||||||
|
const result = processEmailContent(html);
|
||||||
|
expect(result).toContain('href="/path"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not absolutize mailto: or anchors", () => {
|
||||||
|
const html =
|
||||||
|
'<body><a href="mailto:x@y.com">m</a><a href="#top">t</a></body>';
|
||||||
|
const result = processEmailContent(html, undefined, "", base);
|
||||||
|
expect(result).toContain('href="mailto:x@y.com"');
|
||||||
|
expect(result).toContain('href="#top"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("htmlToText", () => {
|
||||||
|
it("strips HTML tags", () => {
|
||||||
|
expect(htmlToText("<b>Bold</b> text")).toBe("Bold text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes HTML entities", () => {
|
||||||
|
expect(htmlToText("Tom & Jerry <3")).toBe("Tom & Jerry <3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapses whitespace and trims", () => {
|
||||||
|
expect(htmlToText(" a\n\n b ")).toBe("a b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty input", () => {
|
||||||
|
expect(htmlToText("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves plain text untouched", () => {
|
||||||
|
expect(htmlToText("Just a subject")).toBe("Just a subject");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("extractInlineCids", () => {
|
describe("extractInlineCids", () => {
|
||||||
it("collects normalized cids referenced by cid: image sources", () => {
|
it("collects normalized cids referenced by cid: image sources", () => {
|
||||||
const html = '<body><img src="cid:ii_abc"/><img src="CID:ii_def"/></body>';
|
const html = '<body><img src="cid:ii_abc"/><img src="CID:ii_def"/></body>';
|
||||||
@@ -216,3 +321,77 @@ describe("extractInlineCids", () => {
|
|||||||
expect(extractInlineCids("").size).toBe(0);
|
expect(extractInlineCids("").size).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("extractLinks", () => {
|
||||||
|
it("collects anchor href + text from HTML", () => {
|
||||||
|
const links = extractLinks(
|
||||||
|
'<p>hi <a href="https://x.example/confirm?t=1">Confirm</a> and <a href="https://x.example/home">Home</a></p>',
|
||||||
|
);
|
||||||
|
expect(links).toEqual([
|
||||||
|
{ href: "https://x.example/confirm?t=1", text: "Confirm" },
|
||||||
|
{ href: "https://x.example/home", text: "Home" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to regex URL extraction for plain text", () => {
|
||||||
|
const links = extractLinks(
|
||||||
|
"Confirm here: https://x.example/verify/abc thanks",
|
||||||
|
);
|
||||||
|
expect(links).toEqual([
|
||||||
|
{
|
||||||
|
href: "https://x.example/verify/abc",
|
||||||
|
text: "https://x.example/verify/abc",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array for empty content", () => {
|
||||||
|
expect(extractLinks("")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractFeedLinks", () => {
|
||||||
|
it("extracts rel=alternate links that carry a type", () => {
|
||||||
|
const html = `<html><head>
|
||||||
|
<link rel="alternate" type="application/rss+xml" href="https://blog.example.com/feed.xml">
|
||||||
|
<link rel="alternate" type="application/atom+xml" href="https://blog.example.com/atom.xml">
|
||||||
|
</head><body>hi</body></html>`;
|
||||||
|
expect(extractFeedLinks(html)).toEqual([
|
||||||
|
{
|
||||||
|
href: "https://blog.example.com/feed.xml",
|
||||||
|
type: "application/rss+xml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "https://blog.example.com/atom.xml",
|
||||||
|
type: "application/atom+xml",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-alternate rels and links without a type", () => {
|
||||||
|
const html = `<head>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://x.com/a.css">
|
||||||
|
<link rel="alternate" href="https://x.com/notype">
|
||||||
|
</head>`;
|
||||||
|
expect(extractFeedLinks(html)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("absolutizes a relative href against the base", () => {
|
||||||
|
const html = `<head><link rel="alternate" type="application/rss+xml" href="/feed.xml"></head>`;
|
||||||
|
expect(extractFeedLinks(html, "https://blog.example.com")).toEqual([
|
||||||
|
{
|
||||||
|
href: "https://blog.example.com/feed.xml",
|
||||||
|
type: "application/rss+xml",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a relative href when no base is given", () => {
|
||||||
|
const html = `<head><link rel="alternate" type="application/rss+xml" href="/feed.xml"></head>`;
|
||||||
|
expect(extractFeedLinks(html)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for plain-text bodies", () => {
|
||||||
|
expect(extractFeedLinks("just text https://x.com/feed")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { parseHTML } from "linkedom";
|
|||||||
import escapeHtml from "escape-html";
|
import escapeHtml from "escape-html";
|
||||||
import type { AttachmentData } from "../types";
|
import type { AttachmentData } from "../types";
|
||||||
|
|
||||||
|
type ParsedDocument = ReturnType<typeof parseHTML>["document"];
|
||||||
|
|
||||||
// Strip surrounding angle brackets and whitespace from a Content-ID so that a
|
// 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".
|
// stored value like "<ii_mpi85rqy0>" matches an HTML reference "cid:ii_mpi85rqy0".
|
||||||
export function normalizeCid(
|
export function normalizeCid(
|
||||||
@@ -28,6 +30,123 @@ export function extractInlineCids(content: string): Set<string> {
|
|||||||
return cids;
|
return cids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render an HTML fragment (or already-plain string) down to plain text: strips
|
||||||
|
// tags and decodes entities. Used for feed <title>s, which must be plain text —
|
||||||
|
// raw markup/entities show literally in readers.
|
||||||
|
export function htmlToText(value: string): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const { document } = parseHTML(`<body>${value}</body>`);
|
||||||
|
return (document.documentElement?.textContent ?? "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the links from an email body for confirmation detection: anchor href +
|
||||||
|
// visible text from HTML, or a regex URL sweep for plain-text bodies. Infra owns
|
||||||
|
// the DOM parse; the domain detector receives plain tuples.
|
||||||
|
export function extractLinks(
|
||||||
|
content: string,
|
||||||
|
): { href: string; text: string }[] {
|
||||||
|
if (!content) return [];
|
||||||
|
|
||||||
|
if (isPlainText(content)) {
|
||||||
|
const urls = content.match(/https?:\/\/[^\s<>"')]+/gi) ?? [];
|
||||||
|
return urls.map((url) => ({ href: url, text: url }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { document } = parseHTML(content);
|
||||||
|
const links: { href: string; text: string }[] = [];
|
||||||
|
document.querySelectorAll("a[href]").forEach((el: Element) => {
|
||||||
|
const href = (el.getAttribute("href") ?? "").trim();
|
||||||
|
if (!href) return;
|
||||||
|
links.push({
|
||||||
|
href,
|
||||||
|
text: ((el as unknown as { textContent?: string }).textContent ?? "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect a newsletter's self-advertised feed declarations from
|
||||||
|
// <link rel="alternate" type="…"> tags. Returns raw href+type tuples; the
|
||||||
|
// domain decides which MIME types count as a feed. Relative hrefs are
|
||||||
|
// absolutized against the sender base (best-effort); only http(s) URLs survive.
|
||||||
|
// Plain-text bodies have no <link> → [].
|
||||||
|
export function extractFeedLinks(
|
||||||
|
content: string,
|
||||||
|
base = "",
|
||||||
|
): { href: string; type: string }[] {
|
||||||
|
if (!content || isPlainText(content)) return [];
|
||||||
|
|
||||||
|
const { document } = parseHTML(content);
|
||||||
|
const links: { href: string; type: string }[] = [];
|
||||||
|
document
|
||||||
|
.querySelectorAll('link[rel~="alternate"][type]')
|
||||||
|
.forEach((el: Element) => {
|
||||||
|
const type = (el.getAttribute("type") ?? "").trim();
|
||||||
|
const rawHref = (el.getAttribute("href") ?? "").trim();
|
||||||
|
if (!type || !rawHref) return;
|
||||||
|
// toAbsolute() skips already-absolute hrefs (returns null), so keep those as-is.
|
||||||
|
const href = /^https?:\/\//i.test(rawHref)
|
||||||
|
? rawHref
|
||||||
|
: (toAbsolute(rawHref, base) ?? "");
|
||||||
|
if (!/^https?:\/\//i.test(href)) return;
|
||||||
|
links.push({ href, type });
|
||||||
|
});
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newsletters frequently defer images via data-src/loading="lazy"; readers don't
|
||||||
|
// run the lazy-loader, so the image renders blank. Promote the real source.
|
||||||
|
function promoteLazyImages(document: ParsedDocument): void {
|
||||||
|
document.querySelectorAll("img").forEach((img: Element) => {
|
||||||
|
const lazySrc =
|
||||||
|
img.getAttribute("data-src") ||
|
||||||
|
img.getAttribute("data-original") ||
|
||||||
|
img.getAttribute("data-lazy-src");
|
||||||
|
if (lazySrc) {
|
||||||
|
const current = (img.getAttribute("src") ?? "").trim();
|
||||||
|
if (!current || /^data:/i.test(current)) {
|
||||||
|
img.setAttribute("src", lazySrc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lazySrcset = img.getAttribute("data-srcset");
|
||||||
|
if (lazySrcset && !img.getAttribute("srcset")) {
|
||||||
|
img.setAttribute("srcset", lazySrcset);
|
||||||
|
}
|
||||||
|
img.removeAttribute("loading");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a single URL against the sender base. Returns null for values that are
|
||||||
|
// already absolute or should never be rewritten (mailto:, data:, cid:, anchors).
|
||||||
|
function toAbsolute(value: string, base: string): string | null {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v || /^(https?:|mailto:|tel:|data:|cid:|#)/i.test(v)) return null;
|
||||||
|
try {
|
||||||
|
return new URL(v, base).href;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most readers ignore xml:base, so relative href/src in content break. Absolutize
|
||||||
|
// them against the sender's site (best-effort, derived from its email domain).
|
||||||
|
// Protocol-relative //host/x are resolved too (they pick up the base's https:).
|
||||||
|
function absolutizeUrls(document: ParsedDocument, base: string): void {
|
||||||
|
if (!base) return;
|
||||||
|
document.querySelectorAll("a[href], area[href]").forEach((el: Element) => {
|
||||||
|
const abs = toAbsolute(el.getAttribute("href") ?? "", base);
|
||||||
|
if (abs) el.setAttribute("href", abs);
|
||||||
|
});
|
||||||
|
document.querySelectorAll("img[src]").forEach((el: Element) => {
|
||||||
|
const abs = toAbsolute(el.getAttribute("src") ?? "", base);
|
||||||
|
if (abs) el.setAttribute("src", abs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function cleanMsoStyles(style: string): string {
|
function cleanMsoStyles(style: string): string {
|
||||||
return style
|
return style
|
||||||
.split(";")
|
.split(";")
|
||||||
@@ -98,11 +217,15 @@ function sanitizeElement(el: Element): void {
|
|||||||
* - Rewrites inline cid: image refs to the stored attachment URL. baseUrl=""
|
* - Rewrites inline cid: image refs to the stored attachment URL. baseUrl=""
|
||||||
* yields relative URLs (entry page, same origin); a baseUrl yields absolute
|
* yields relative URLs (entry page, same origin); a baseUrl yields absolute
|
||||||
* URLs (feeds, for external RSS readers).
|
* URLs (feeds, for external RSS readers).
|
||||||
|
* - Promotes lazy-loaded images (data-src → src, strips loading="lazy").
|
||||||
|
* - Absolutizes relative href/src against senderBaseUrl (the sender's site,
|
||||||
|
* best-effort) so links/images don't break in readers that ignore xml:base.
|
||||||
*/
|
*/
|
||||||
export function processEmailContent(
|
export function processEmailContent(
|
||||||
content: string,
|
content: string,
|
||||||
attachments?: AttachmentData[],
|
attachments?: AttachmentData[],
|
||||||
baseUrl = "",
|
baseUrl = "",
|
||||||
|
senderBaseUrl = "",
|
||||||
): string {
|
): string {
|
||||||
if (!content) return "";
|
if (!content) return "";
|
||||||
|
|
||||||
@@ -124,6 +247,11 @@ export function processEmailContent(
|
|||||||
|
|
||||||
document.querySelectorAll("*").forEach((el: Element) => sanitizeElement(el));
|
document.querySelectorAll("*").forEach((el: Element) => sanitizeElement(el));
|
||||||
|
|
||||||
|
promoteLazyImages(document);
|
||||||
|
// Absolutize first: cid: refs are skipped here (not http(s)), then rewritten
|
||||||
|
// below to our /files/ URL — which must NOT be absolutized to the sender.
|
||||||
|
absolutizeUrls(document, senderBaseUrl);
|
||||||
|
|
||||||
if (cidMap.size > 0) {
|
if (cidMap.size > 0) {
|
||||||
document
|
document
|
||||||
.querySelectorAll("[src]")
|
.querySelectorAll("[src]")
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { FeedConfig, EmailData } from "../types";
|
||||||
|
|
||||||
|
export interface FeedValidators {
|
||||||
|
etag: string;
|
||||||
|
lastModified: string;
|
||||||
|
maxReceivedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute HTTP cache validators (ETag + Last-Modified) for a feed.
|
||||||
|
* The ETag is derived from the feed format prefix, feedId, email count, and max
|
||||||
|
* receivedAt, making it a strong deterministic validator that changes whenever
|
||||||
|
* the feed content changes.
|
||||||
|
*/
|
||||||
|
export function computeFeedValidators(
|
||||||
|
format: "rss" | "atom",
|
||||||
|
feedId: string,
|
||||||
|
feedConfig: FeedConfig,
|
||||||
|
emails: EmailData[],
|
||||||
|
): FeedValidators {
|
||||||
|
const maxReceivedAt =
|
||||||
|
emails.length > 0
|
||||||
|
? Math.max(...emails.map((e) => e.receivedAt))
|
||||||
|
: (feedConfig.created_at ?? 0);
|
||||||
|
|
||||||
|
const hash = `${format}-${feedId}-${emails.length}-${maxReceivedAt}`;
|
||||||
|
const etag = `"${hash}"`;
|
||||||
|
const lastModified = new Date(maxReceivedAt).toUTCString();
|
||||||
|
|
||||||
|
return { etag, lastModified, maxReceivedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the request carries a matching conditional GET header,
|
||||||
|
* meaning a 304 Not Modified response is appropriate.
|
||||||
|
*/
|
||||||
|
export function isNotModified(
|
||||||
|
req: Request,
|
||||||
|
validators: FeedValidators,
|
||||||
|
): boolean {
|
||||||
|
const ifNoneMatch = req.headers.get("If-None-Match");
|
||||||
|
if (ifNoneMatch !== null) {
|
||||||
|
return ifNoneMatch === validators.etag;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ifModifiedSince = req.headers.get("If-Modified-Since");
|
||||||
|
if (ifModifiedSince !== null) {
|
||||||
|
const clientTime = new Date(ifModifiedSince).getTime();
|
||||||
|
return !isNaN(clientTime) && clientTime >= validators.maxReceivedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a 304 Not Modified response with the standard cache validator headers.
|
||||||
|
*/
|
||||||
|
export function notModifiedResponse(validators: FeedValidators): Response {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 304,
|
||||||
|
headers: {
|
||||||
|
ETag: validators.etag,
|
||||||
|
"Last-Modified": validators.lastModified,
|
||||||
|
"Cache-Control": "max-age=1800",
|
||||||
|
"X-Robots-Tag": "noindex",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
feedRssUrl,
|
||||||
|
feedAtomUrl,
|
||||||
|
feedJsonUrl,
|
||||||
|
feedFormatUrl,
|
||||||
|
feedValidatorUrl,
|
||||||
|
} from "./urls";
|
||||||
|
import { Env } from "../types";
|
||||||
|
|
||||||
|
const env = { DOMAIN: "getmynews.app" } as Env;
|
||||||
|
const feedId = "gAf6wiKyanpppcKX9o3B_Q";
|
||||||
|
|
||||||
|
describe("feed URL builders", () => {
|
||||||
|
it("builds RSS/Atom/JSON feed URLs", () => {
|
||||||
|
expect(feedRssUrl(feedId, env)).toBe(
|
||||||
|
"https://getmynews.app/rss/gAf6wiKyanpppcKX9o3B_Q",
|
||||||
|
);
|
||||||
|
expect(feedAtomUrl(feedId, env)).toBe(
|
||||||
|
"https://getmynews.app/atom/gAf6wiKyanpppcKX9o3B_Q",
|
||||||
|
);
|
||||||
|
expect(feedJsonUrl(feedId, env)).toBe(
|
||||||
|
"https://getmynews.app/json/gAf6wiKyanpppcKX9o3B_Q",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a format to its feed URL", () => {
|
||||||
|
expect(feedFormatUrl("rss", feedId, env)).toBe(feedRssUrl(feedId, env));
|
||||||
|
expect(feedFormatUrl("atom", feedId, env)).toBe(feedAtomUrl(feedId, env));
|
||||||
|
expect(feedFormatUrl("json", feedId, env)).toBe(feedJsonUrl(feedId, env));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("feedValidatorUrl", () => {
|
||||||
|
it("points JSON feeds at validator.jsonfeed.org with the encoded feed URL", () => {
|
||||||
|
expect(feedValidatorUrl("json", feedId, env)).toBe(
|
||||||
|
"https://validator.jsonfeed.org/?url=" +
|
||||||
|
encodeURIComponent(feedJsonUrl(feedId, env)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("points RSS and Atom feeds at the W3C feed validator", () => {
|
||||||
|
expect(feedValidatorUrl("rss", feedId, env)).toBe(
|
||||||
|
"https://validator.w3.org/feed/check.cgi?url=" +
|
||||||
|
encodeURIComponent(feedRssUrl(feedId, env)),
|
||||||
|
);
|
||||||
|
expect(feedValidatorUrl("atom", feedId, env)).toBe(
|
||||||
|
"https://validator.w3.org/feed/check.cgi?url=" +
|
||||||
|
encodeURIComponent(feedAtomUrl(feedId, env)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("percent-encodes the feed URL so the validator query is well-formed", () => {
|
||||||
|
const url = feedValidatorUrl("json", feedId, env);
|
||||||
|
expect(url).toContain("https%3A%2F%2Fgetmynews.app%2Fjson%2F");
|
||||||
|
expect(url).not.toContain("?url=https://");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
|
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||||
|
|
||||||
export function baseUrl(env: Env): string {
|
export function baseUrl(env: Env): string {
|
||||||
return `https://${env.DOMAIN}`;
|
return `https://${env.DOMAIN}`;
|
||||||
@@ -12,6 +13,16 @@ export function feedAtomUrl(feedId: string, env: Env): string {
|
|||||||
return `${baseUrl(env)}/atom/${feedId}`;
|
return `${baseUrl(env)}/atom/${feedId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function feedJsonUrl(feedId: string, env: Env): string {
|
||||||
|
return `${baseUrl(env)}/json/${feedId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Path of an email's public HTML view. The single source of truth for the
|
||||||
|
* `/entries/:feedId/:entryId` route shape (entryId = the email's receivedAt). */
|
||||||
|
export function entryPath(feedId: string, receivedAt: number): string {
|
||||||
|
return `/entries/${feedId}/${receivedAt}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function feedUrl(
|
export function feedUrl(
|
||||||
format: "rss" | "atom",
|
format: "rss" | "atom",
|
||||||
feedId: string,
|
feedId: string,
|
||||||
@@ -20,8 +31,39 @@ export function feedUrl(
|
|||||||
return format === "rss" ? feedRssUrl(feedId, env) : feedAtomUrl(feedId, env);
|
return format === "rss" ? feedRssUrl(feedId, env) : feedAtomUrl(feedId, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function feedEmailAddress(feedId: string, env: Env): string {
|
export type FeedFormat = "rss" | "atom" | "json";
|
||||||
return `${feedId}@${env.EMAIL_DOMAIN ?? env.DOMAIN}`;
|
|
||||||
|
export function feedFormatUrl(
|
||||||
|
format: FeedFormat,
|
||||||
|
feedId: string,
|
||||||
|
env: Env,
|
||||||
|
): string {
|
||||||
|
if (format === "atom") return feedAtomUrl(feedId, env);
|
||||||
|
if (format === "json") return feedJsonUrl(feedId, env);
|
||||||
|
return feedRssUrl(feedId, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link to a third-party validator for the public feed URL: the W3C Feed
|
||||||
|
* Validator for RSS/Atom, validator.jsonfeed.org for JSON Feed. Used in the
|
||||||
|
* admin UI so an operator can confirm a feed parses in real readers.
|
||||||
|
*/
|
||||||
|
export function feedValidatorUrl(
|
||||||
|
format: FeedFormat,
|
||||||
|
feedId: string,
|
||||||
|
env: Env,
|
||||||
|
): string {
|
||||||
|
const encoded = encodeURIComponent(feedFormatUrl(format, feedId, env));
|
||||||
|
return format === "json"
|
||||||
|
? `https://validator.jsonfeed.org/?url=${encoded}`
|
||||||
|
: `https://validator.w3.org/feed/check.cgi?url=${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function feedEmailAddress(mailboxId: string, env: Env): string {
|
||||||
|
// The mailbox→address shape lives on the VO; this edge only resolves the domain.
|
||||||
|
return MailboxId.unchecked(mailboxId).emailAddress(
|
||||||
|
env.EMAIL_DOMAIN ?? env.DOMAIN,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function feedTopicPattern(env: Env): RegExp {
|
export function feedTopicPattern(env: Env): RegExp {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ async function buildFeedXml(
|
|||||||
title: `Newsletter Feed ${feedId.value}`,
|
title: `Newsletter Feed ${feedId.value}`,
|
||||||
description: "Converted email newsletter",
|
description: "Converted email newsletter",
|
||||||
language: "en",
|
language: "en",
|
||||||
|
mailbox_id: "",
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+637
-2
@@ -5,6 +5,10 @@ import app from "./admin";
|
|||||||
import { createMockEnv, server } from "../test/setup";
|
import { createMockEnv, server } from "../test/setup";
|
||||||
import { getCounters } from "../application/stats";
|
import { getCounters } from "../application/stats";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
|
import { Feed } from "../domain/feed.aggregate";
|
||||||
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import { MailboxId } from "../domain/value-objects/mailbox-id";
|
||||||
|
|
||||||
describe("Admin Routes", () => {
|
describe("Admin Routes", () => {
|
||||||
let testApp: Hono;
|
let testApp: Hono;
|
||||||
@@ -147,8 +151,10 @@ describe("Admin Routes", () => {
|
|||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(302); // Redirects back to dashboard
|
expect(res.status).toBe(302); // Redirects to new feed's emails page
|
||||||
expect(res.headers.get("Location")).toBe("/admin?view=list#your-feeds");
|
expect(res.headers.get("Location")).toMatch(
|
||||||
|
/^\/admin\/feeds\/.+\/emails$/,
|
||||||
|
);
|
||||||
|
|
||||||
// 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(
|
||||||
@@ -168,6 +174,34 @@ describe("Admin Routes", () => {
|
|||||||
expect(feedConfig).toBeTruthy();
|
expect(feedConfig).toBeTruthy();
|
||||||
expect((feedConfig as any).title).toBe("Test Feed");
|
expect((feedConfig as any).title).toBe("Test Feed");
|
||||||
expect((feedConfig as any).description).toBe("Test Description");
|
expect((feedConfig as any).description).toBe("Test Description");
|
||||||
|
|
||||||
|
// Two-id model: the feed id is an opaque read id; the inbound address is
|
||||||
|
// a separate noun.noun.NN mailbox, mapped via the inbound: index.
|
||||||
|
const mailboxId = (feedConfig as any).mailbox_id as string;
|
||||||
|
expect(mailboxId).toMatch(/^[a-z]+\.[a-z]+\.\d{2}$/);
|
||||||
|
expect(feedId).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||||
|
expect(feedId).not.toBe(mailboxId);
|
||||||
|
expect((feedList?.feeds[0] as any).mailbox_id).toBe(mailboxId);
|
||||||
|
expect(
|
||||||
|
await mockEnv.EMAIL_STORAGE.get(`inbound:${mailboxId}`, "text"),
|
||||||
|
).toBe(feedId);
|
||||||
|
|
||||||
|
// The dashboard shows the inbound address and the opaque feed URL,
|
||||||
|
// distinctly — and never exposes the address as a readable feed URL.
|
||||||
|
const dash = await request("/admin", {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
const html = await dash.text();
|
||||||
|
expect(html).toContain(`${mailboxId}@test.getmynews.app`);
|
||||||
|
expect(html).toContain(`/rss/${feedId}`);
|
||||||
|
expect(html).not.toContain(`/rss/${mailboxId}`);
|
||||||
|
|
||||||
|
// The feed-formats block surfaces all three formats (incl. JSON Feed)
|
||||||
|
// plus per-format validator links.
|
||||||
|
expect(html).toContain(`/atom/${feedId}`);
|
||||||
|
expect(html).toContain(`/json/${feedId}`);
|
||||||
|
expect(html).toContain("validator.jsonfeed.org");
|
||||||
|
expect(html).toContain("validator.w3.org/feed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should reject feed creation with missing title", async () => {
|
it("should reject feed creation with missing title", async () => {
|
||||||
@@ -732,6 +766,15 @@ describe("Admin Routes", () => {
|
|||||||
it("lists attachments with download links on the email detail page", async () => {
|
it("lists attachments with download links on the email detail page", async () => {
|
||||||
const authCookie = await loginAndGetCookie();
|
const authCookie = await loginAndGetCookie();
|
||||||
const feedId = "detail-feed";
|
const feedId = "detail-feed";
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${feedId}:config`,
|
||||||
|
JSON.stringify({
|
||||||
|
title: "Detail Feed",
|
||||||
|
mailbox_id: "detail.feed.10",
|
||||||
|
language: "en",
|
||||||
|
created_at: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const emailKey = `feed:${feedId}:1`;
|
const emailKey = `feed:${feedId}:1`;
|
||||||
await mockEnv.EMAIL_STORAGE.put(
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
emailKey,
|
emailKey,
|
||||||
@@ -769,6 +812,15 @@ describe("Admin Routes", () => {
|
|||||||
it("renders inline cid images in place and hides them from the attachments list", async () => {
|
it("renders inline cid images in place and hides them from the attachments list", async () => {
|
||||||
const authCookie = await loginAndGetCookie();
|
const authCookie = await loginAndGetCookie();
|
||||||
const feedId = "detail-feed";
|
const feedId = "detail-feed";
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${feedId}:config`,
|
||||||
|
JSON.stringify({
|
||||||
|
title: "Detail Feed",
|
||||||
|
mailbox_id: "detail.feed.10",
|
||||||
|
language: "en",
|
||||||
|
created_at: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const emailKey = `feed:${feedId}:3`;
|
const emailKey = `feed:${feedId}:3`;
|
||||||
await mockEnv.EMAIL_STORAGE.put(
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
emailKey,
|
emailKey,
|
||||||
@@ -814,6 +866,15 @@ describe("Admin Routes", () => {
|
|||||||
it("does not render an attachments section when the email has none", async () => {
|
it("does not render an attachments section when the email has none", async () => {
|
||||||
const authCookie = await loginAndGetCookie();
|
const authCookie = await loginAndGetCookie();
|
||||||
const feedId = "detail-feed";
|
const feedId = "detail-feed";
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${feedId}:config`,
|
||||||
|
JSON.stringify({
|
||||||
|
title: "Detail Feed",
|
||||||
|
mailbox_id: "detail.feed.10",
|
||||||
|
language: "en",
|
||||||
|
created_at: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const emailKey = `feed:${feedId}:2`;
|
const emailKey = `feed:${feedId}:2`;
|
||||||
await mockEnv.EMAIL_STORAGE.put(
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
emailKey,
|
emailKey,
|
||||||
@@ -835,6 +896,39 @@ describe("Admin Routes", () => {
|
|||||||
expect(body).not.toContain("Attachments");
|
expect(body).not.toContain("Attachments");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("links to the public entry page using the feed id and receivedAt", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const feedId = "detail-feed";
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${feedId}:config`,
|
||||||
|
JSON.stringify({
|
||||||
|
title: "Detail Feed",
|
||||||
|
mailbox_id: "detail.feed.10",
|
||||||
|
language: "en",
|
||||||
|
created_at: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const emailKey = `feed:${feedId}:2`;
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
emailKey,
|
||||||
|
JSON.stringify({
|
||||||
|
subject: "Linkable",
|
||||||
|
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).toContain(`href="/entries/${feedId}/2"`);
|
||||||
|
});
|
||||||
|
|
||||||
it("form-based bulk-delete also removes R2 attachments", async () => {
|
it("form-based bulk-delete also removes R2 attachments", async () => {
|
||||||
const r2Env = createMockEnv({ withR2: true }) as unknown as Env;
|
const r2Env = createMockEnv({ withR2: true }) as unknown as Env;
|
||||||
const bucket = r2Env.ATTACHMENT_BUCKET as unknown as {
|
const bucket = r2Env.ATTACHMENT_BUCKET as unknown as {
|
||||||
@@ -1038,4 +1132,545 @@ describe("Admin Routes", () => {
|
|||||||
expect(cfg.allowed_senders).toContain("alice@example.com");
|
expect(cfg.allowed_senders).toContain("alice@example.com");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Confirmation features", () => {
|
||||||
|
it("detail view shows confirmation-section with links when email has confirmation metadata", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
|
||||||
|
// Create feed aggregate
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const mailboxId = MailboxId.unchecked("confirm.test.01");
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Confirm Test Feed",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
// Mint an email key and put the email body
|
||||||
|
const emailKey = repo.newEmailKey(feedId);
|
||||||
|
await repo.putEmail(emailKey, {
|
||||||
|
subject: "Please confirm your subscription",
|
||||||
|
from: "newsletter@example.com",
|
||||||
|
content: "<p>Click to confirm</p>",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
headers: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ingest the email into the aggregate with confirmation links
|
||||||
|
feed.ingest(
|
||||||
|
{
|
||||||
|
key: emailKey,
|
||||||
|
subject: "Please confirm your subscription",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
confirmation: { links: ["https://example.com/confirm?t=1"] },
|
||||||
|
},
|
||||||
|
{ maxBytes: 10_000_000 },
|
||||||
|
);
|
||||||
|
await repo.saveMetadata(feed);
|
||||||
|
|
||||||
|
const res = await request(`/admin/emails/${emailKey}`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("confirmation-section");
|
||||||
|
expect(body).toContain("https://example.com/confirm?t=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("email list shows confirmation-badge for emails with confirmation metadata", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
|
||||||
|
// Create feed aggregate
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const mailboxId = MailboxId.unchecked("confirm.badge.02");
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Confirm Badge Feed",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const emailKey = repo.newEmailKey(feedId);
|
||||||
|
await repo.putEmail(emailKey, {
|
||||||
|
subject: "Confirm subscription",
|
||||||
|
from: "newsletter@example.com",
|
||||||
|
content: "<p>Click to confirm</p>",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
headers: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.ingest(
|
||||||
|
{
|
||||||
|
key: emailKey,
|
||||||
|
subject: "Confirm subscription",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
confirmation: { links: ["https://example.com/confirm?t=1"] },
|
||||||
|
},
|
||||||
|
{ maxBytes: 10_000_000 },
|
||||||
|
);
|
||||||
|
await repo.saveMetadata(feed);
|
||||||
|
|
||||||
|
const res = await request(`/admin/feeds/${feedId.value}/emails`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("confirmation-badge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismiss route clears pendingConfirmation flag", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
|
||||||
|
// Create feed aggregate with a confirmation email
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const mailboxId = MailboxId.unchecked("confirm.dismiss.03");
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Dismiss Test Feed",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const emailKey = repo.newEmailKey(feedId);
|
||||||
|
await repo.putEmail(emailKey, {
|
||||||
|
subject: "Confirm subscription",
|
||||||
|
from: "newsletter@example.com",
|
||||||
|
content: "<p>Click to confirm</p>",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
headers: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.ingest(
|
||||||
|
{
|
||||||
|
key: emailKey,
|
||||||
|
subject: "Confirm subscription",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
confirmation: { links: ["https://example.com/confirm?t=2"] },
|
||||||
|
},
|
||||||
|
{ maxBytes: 10_000_000 },
|
||||||
|
);
|
||||||
|
await repo.saveMetadata(feed);
|
||||||
|
|
||||||
|
// Verify flag is set
|
||||||
|
expect(feed.pendingConfirmation).toBe(true);
|
||||||
|
|
||||||
|
// Call dismiss route
|
||||||
|
const dismissRes = await request(
|
||||||
|
`/admin/feeds/${feedId.value}/confirmation/dismiss`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Origin: `https://${mockEnv.DOMAIN}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(dismissRes.status).toBe(200);
|
||||||
|
const payload = (await dismissRes.json()) as any;
|
||||||
|
expect(payload.ok).toBe(true);
|
||||||
|
|
||||||
|
// Reload feed from repo and check flag is cleared
|
||||||
|
const reloaded = await repo.load(feedId);
|
||||||
|
expect(reloaded).not.toBeNull();
|
||||||
|
expect(reloaded!.pendingConfirmation).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dashboard list view shows pill-confirmation for feeds with pendingConfirmation", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
|
||||||
|
// Create feed aggregate with a confirmation email
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const mailboxId = MailboxId.unchecked("confirm.dash.04");
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Dashboard Confirm Feed",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const emailKey = repo.newEmailKey(feedId);
|
||||||
|
await repo.putEmail(emailKey, {
|
||||||
|
subject: "Confirm your subscription",
|
||||||
|
from: "newsletter@example.com",
|
||||||
|
content: "<p>Click to confirm</p>",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
headers: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.ingest(
|
||||||
|
{
|
||||||
|
key: emailKey,
|
||||||
|
subject: "Confirm your subscription",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
confirmation: { links: ["https://x/confirm"] },
|
||||||
|
},
|
||||||
|
{ maxBytes: 1_000_000 },
|
||||||
|
);
|
||||||
|
await repo.saveMetadata(feed);
|
||||||
|
|
||||||
|
const res = await request("/admin?view=list", {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("pill-confirmation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dashboard table view shows pill-confirmation for feeds with pendingConfirmation", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
|
||||||
|
// Create feed aggregate with a confirmation email — same seeding as list view test
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const mailboxId = MailboxId.unchecked("confirm.table.05");
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Dashboard Confirm Feed Table",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const emailKey = repo.newEmailKey(feedId);
|
||||||
|
await repo.putEmail(emailKey, {
|
||||||
|
subject: "Confirm your subscription",
|
||||||
|
from: "newsletter@example.com",
|
||||||
|
content: "<p>Click to confirm</p>",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
headers: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.ingest(
|
||||||
|
{
|
||||||
|
key: emailKey,
|
||||||
|
subject: "Confirm your subscription",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
confirmation: { links: ["https://x/confirm"] },
|
||||||
|
},
|
||||||
|
{ maxBytes: 1_000_000 },
|
||||||
|
);
|
||||||
|
await repo.saveMetadata(feed);
|
||||||
|
|
||||||
|
const res = await request("/admin?view=table", {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("pill-confirmation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("feed emails page shows confirmation-banner when pendingConfirmation is true", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
|
||||||
|
// Seed a feed with a confirmation email so pendingConfirmation is raised
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const mailboxId = MailboxId.unchecked("confirm.emails.06");
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Emails Page Confirm Feed",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const emailKey = repo.newEmailKey(feedId);
|
||||||
|
await repo.putEmail(emailKey, {
|
||||||
|
subject: "Confirm subscription",
|
||||||
|
from: "newsletter@example.com",
|
||||||
|
content: "<p>Click to confirm</p>",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
headers: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
feed.ingest(
|
||||||
|
{
|
||||||
|
key: emailKey,
|
||||||
|
subject: "Confirm subscription",
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
confirmation: { links: ["https://example.com/confirm?t=6"] },
|
||||||
|
},
|
||||||
|
{ maxBytes: 10_000_000 },
|
||||||
|
);
|
||||||
|
await repo.saveMetadata(feed);
|
||||||
|
|
||||||
|
const res = await request(`/admin/feeds/${feedId.value}/emails`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("confirmation-banner");
|
||||||
|
expect(body).toContain("confirmation-dismiss");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("feed emails page reuses the dashboard Subscribe chips design", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const mailboxId = MailboxId.unchecked("subscribe.chips.07");
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Chips Detail Feed",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const res = await request(`/admin/feeds/${feedId.value}/emails`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
|
||||||
|
// The Subscribe chips block surfaces all three formats with copy/open/validate.
|
||||||
|
expect(body).toContain("feed-formats-chips");
|
||||||
|
expect(body).toContain(`/rss/${feedId.value}`);
|
||||||
|
expect(body).toContain(`/atom/${feedId.value}`);
|
||||||
|
expect(body).toContain(`/json/${feedId.value}`);
|
||||||
|
expect(body).toContain(`${mailboxId.value}@test.getmynews.app`);
|
||||||
|
|
||||||
|
// The old W3C validator-image block is gone.
|
||||||
|
expect(body).not.toContain("validator.w3.org/feed/images");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Sender-in-title toggle", () => {
|
||||||
|
it("edit form renders the checkbox, unchecked by default", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Title Toggle Feed",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId: MailboxId.unchecked("title.toggle.01") },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const res = await request(`/admin/feeds/${feedId.value}/edit`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain('name="sender_in_title"');
|
||||||
|
expect(body).toContain("Show sender in entry titles");
|
||||||
|
expect(body).not.toContain("checked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists the toggle through edit and reflects it back as checked", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Title Toggle Feed",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId: MailboxId.unchecked("title.toggle.02") },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("title", "Title Toggle Feed");
|
||||||
|
form.append("sender_in_title", "true");
|
||||||
|
const post = await request(`/admin/feeds/${feedId.value}/edit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
expect(post.status).toBe(302);
|
||||||
|
|
||||||
|
const cfg = await repo.getConfig(feedId);
|
||||||
|
expect(cfg?.sender_in_title).toBe(true);
|
||||||
|
|
||||||
|
const editPage = await request(`/admin/feeds/${feedId.value}/edit`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
expect(await editPage.text()).toContain("checked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dashboard shows pill-native for feeds with hasNativeFeed", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const mailboxId = MailboxId.unchecked("native.pill.08");
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "N",
|
||||||
|
language: "en",
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId },
|
||||||
|
);
|
||||||
|
feed.ingest(
|
||||||
|
{ key: "k1", subject: "s", receivedAt: 1, size: 10 },
|
||||||
|
{
|
||||||
|
maxBytes: 1e9,
|
||||||
|
nativeFeeds: {
|
||||||
|
senderKey: "a@x.com",
|
||||||
|
feeds: [{ url: "https://x.com/rss", type: "rss" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const res = await request("/admin", { headers: { Cookie: authCookie } });
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("pill-native");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the toggle when the checkbox is omitted (unchecked)", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{
|
||||||
|
title: "Title Toggle Feed",
|
||||||
|
language: "en",
|
||||||
|
senderInTitle: true,
|
||||||
|
allowedSenders: [],
|
||||||
|
blockedSenders: [],
|
||||||
|
},
|
||||||
|
{ mailboxId: MailboxId.unchecked("title.toggle.03") },
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("title", "Title Toggle Feed");
|
||||||
|
// No sender_in_title field ⇒ unchecked.
|
||||||
|
const post = await request(`/admin/feeds/${feedId.value}/edit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
expect(post.status).toBe(302);
|
||||||
|
|
||||||
|
const cfg = await repo.getConfig(feedId);
|
||||||
|
expect(cfg?.sender_in_title).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("feed detail shows a native-feeds group when a native feed was detected", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const mailboxId = MailboxId.unchecked("native.detail.07");
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{ title: "N", language: "en", allowedSenders: [], blockedSenders: [] },
|
||||||
|
{ mailboxId },
|
||||||
|
);
|
||||||
|
feed.ingest(
|
||||||
|
{ key: "k1", subject: "s", receivedAt: 1, size: 10 },
|
||||||
|
{
|
||||||
|
maxBytes: 1e9,
|
||||||
|
nativeFeeds: {
|
||||||
|
senderKey: "a@x.com",
|
||||||
|
feeds: [{ url: "https://blog.example.com/feed.xml", type: "rss" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const res = await request(`/admin/feeds/${feedId.value}/emails`, {
|
||||||
|
headers: { Cookie: authCookie },
|
||||||
|
});
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("native-feeds");
|
||||||
|
expect(body).toContain("https://blog.example.com/feed.xml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("native-feed dismiss route clears the flag", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||||
|
const feedId = FeedId.generate();
|
||||||
|
const mailboxId = MailboxId.unchecked("native.dismiss.09");
|
||||||
|
const feed = Feed.create(
|
||||||
|
feedId,
|
||||||
|
{ title: "N", language: "en", allowedSenders: [], blockedSenders: [] },
|
||||||
|
{ mailboxId },
|
||||||
|
);
|
||||||
|
feed.ingest(
|
||||||
|
{ key: "k1", subject: "s", receivedAt: 1, size: 10 },
|
||||||
|
{
|
||||||
|
maxBytes: 1e9,
|
||||||
|
nativeFeeds: {
|
||||||
|
senderKey: "a@x.com",
|
||||||
|
feeds: [{ url: "https://x.com/rss", type: "rss" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await repo.save(feed);
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
`/admin/feeds/${feedId.value}/native-feed/dismiss`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Origin: `https://${mockEnv.DOMAIN}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const reloaded = await repo.load(feedId);
|
||||||
|
expect(reloaded!.hasNativeFeed()).toBe(false);
|
||||||
|
expect(reloaded!.nativeFeeds()).toHaveLength(1); // URLs preserved
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+46
-155
@@ -7,17 +7,21 @@ import { csrf } from "hono/csrf";
|
|||||||
import { ADMIN_COOKIE_MAX_AGE } from "../config/constants";
|
import { ADMIN_COOKIE_MAX_AGE } from "../config/constants";
|
||||||
import { logger } from "../infrastructure/logger";
|
import { logger } from "../infrastructure/logger";
|
||||||
import { timingSafeEqual, checkProxyAuth } from "../infrastructure/auth";
|
import { timingSafeEqual, checkProxyAuth } from "../infrastructure/auth";
|
||||||
import { Layout, clampText } from "./admin/ui";
|
import {
|
||||||
|
Layout,
|
||||||
|
clampText,
|
||||||
|
CopyIcon,
|
||||||
|
CheckIcon,
|
||||||
|
FeedFormats,
|
||||||
|
ExpiryBadge,
|
||||||
|
} from "./admin/ui";
|
||||||
import { FeedRepository } from "../infrastructure/feed-repository";
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
import { FeedId } from "../domain/value-objects/feed-id";
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
import { editFeedDetails } from "../application/feed-service";
|
import { editFeedDetails } from "../application/feed-service";
|
||||||
import {
|
import { feedEmailAddress } from "../infrastructure/urls";
|
||||||
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 { handleOpml } from "./opml";
|
||||||
import { dashboardScript } from "../scripts/generated/dashboard";
|
import { dashboardScript } from "../scripts/generated/dashboard";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
@@ -200,41 +204,6 @@ app.get("/logout", (c) => {
|
|||||||
// dashboardScript is compiled from src/scripts/client/dashboard.ts via `npm run build:client`.
|
// dashboardScript is compiled from src/scripts/client/dashboard.ts via `npm run build:client`.
|
||||||
// It is imported from src/scripts/generated/dashboard.ts above.
|
// It is imported from src/scripts/generated/dashboard.ts above.
|
||||||
|
|
||||||
// ── Shared SVG icons ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const CopyIcon = () => (
|
|
||||||
<svg
|
|
||||||
class="copy-icon copy-icon-original"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CheckIcon = () => (
|
|
||||||
<svg
|
|
||||||
class="copy-icon copy-icon-success"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M20 6L9 17l-5-5"></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
type CopyFieldInlineProps = {
|
type CopyFieldInlineProps = {
|
||||||
value: string;
|
value: string;
|
||||||
emailAddress?: string;
|
emailAddress?: string;
|
||||||
@@ -254,34 +223,17 @@ const CopyFieldInline = ({ value }: CopyFieldInlineProps) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
function formatExpiry(expiresAt: number): { label: string; expired: boolean } {
|
const ConfirmationPill = ({ feedId }: { feedId: string }) => (
|
||||||
const remaining = expiresAt - Date.now();
|
<a class="pill pill-confirmation" href={`/admin/feeds/${feedId}/emails`}>
|
||||||
if (remaining <= 0) {
|
Confirmation pending
|
||||||
const h = Math.floor(-remaining / 3_600_000);
|
</a>
|
||||||
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 NativeFeedPill = ({ feedId }: { feedId: string }) => (
|
||||||
const { label, expired } = formatExpiry(expiresAt);
|
<a class="pill pill-native" href={`/admin/feeds/${feedId}/emails`}>
|
||||||
return (
|
Native feed available
|
||||||
<span class={`pill ${expired ? "pill-expired" : "pill-expiry"}`}>
|
</a>
|
||||||
{label}
|
);
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Admin dashboard route
|
// Admin dashboard route
|
||||||
app.get("/", async (c) => {
|
app.get("/", async (c) => {
|
||||||
@@ -521,8 +473,7 @@ app.get("/", async (c) => {
|
|||||||
<col data-col="title" style="width: 280px;" />
|
<col data-col="title" style="width: 280px;" />
|
||||||
<col data-col="feedId" style="width: 150px;" />
|
<col data-col="feedId" style="width: 150px;" />
|
||||||
<col data-col="email" style="width: 200px;" />
|
<col data-col="email" style="width: 200px;" />
|
||||||
<col data-col="rss" style="width: 190px;" />
|
<col data-col="formats" style="width: 230px;" />
|
||||||
<col data-col="atom" style="width: 190px;" />
|
|
||||||
<col data-col="expires" style="width: 130px;" />
|
<col data-col="expires" style="width: 130px;" />
|
||||||
<col data-col="actions" style="width: 170px;" />
|
<col data-col="actions" style="width: 170px;" />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
@@ -601,47 +552,11 @@ app.get("/", async (c) => {
|
|||||||
title="Resize"
|
title="Resize"
|
||||||
></div>
|
></div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th class="th-resizable">
|
||||||
class="th-resizable"
|
<span>Formats</span>
|
||||||
data-sort-key="rss"
|
|
||||||
aria-sort="none"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="th-button"
|
|
||||||
data-sort-key="rss"
|
|
||||||
>
|
|
||||||
RSS
|
|
||||||
<span
|
|
||||||
class="sort-indicator"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
<div
|
<div
|
||||||
class="col-resizer"
|
class="col-resizer"
|
||||||
data-col="rss"
|
data-col="formats"
|
||||||
title="Resize"
|
|
||||||
></div>
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="th-resizable"
|
|
||||||
data-sort-key="atom"
|
|
||||||
aria-sort="none"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="th-button"
|
|
||||||
data-sort-key="atom"
|
|
||||||
>
|
|
||||||
Atom
|
|
||||||
<span
|
|
||||||
class="sort-indicator"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
class="col-resizer"
|
|
||||||
data-col="atom"
|
|
||||||
title="Resize"
|
title="Resize"
|
||||||
></div>
|
></div>
|
||||||
</th>
|
</th>
|
||||||
@@ -665,16 +580,15 @@ app.get("/", async (c) => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="feed-table-body">
|
<tbody id="feed-table-body">
|
||||||
{feedsWithConfig.map((feed) => {
|
{feedsWithConfig.map((feed) => {
|
||||||
const emailAddress = feedEmailAddress(feed.id, env);
|
const emailAddress = feedEmailAddress(
|
||||||
const rssUrl = feedRssUrl(feed.id, env);
|
feed.mailbox_id,
|
||||||
const atomUrl = feedAtomUrl(feed.id, env);
|
env,
|
||||||
|
);
|
||||||
const titleDisplay = clampText(feed.title, 160);
|
const titleDisplay = clampText(feed.title, 160);
|
||||||
const titleHover = clampText(feed.title, 1000);
|
const titleHover = clampText(feed.title, 1000);
|
||||||
const sortTitle = titleHover.toLowerCase();
|
const sortTitle = titleHover.toLowerCase();
|
||||||
const sortFeedId = feed.id.toLowerCase();
|
const sortFeedId = feed.id.toLowerCase();
|
||||||
const sortEmail = emailAddress.toLowerCase();
|
const sortEmail = emailAddress.toLowerCase();
|
||||||
const sortRss = rssUrl.toLowerCase();
|
|
||||||
const sortAtom = atomUrl.toLowerCase();
|
|
||||||
const descDisplay = clampText(
|
const descDisplay = clampText(
|
||||||
feed.description || "",
|
feed.description || "",
|
||||||
220,
|
220,
|
||||||
@@ -694,8 +608,6 @@ app.get("/", async (c) => {
|
|||||||
data-sort-title={sortTitle}
|
data-sort-title={sortTitle}
|
||||||
data-sort-feed-id={sortFeedId}
|
data-sort-feed-id={sortFeedId}
|
||||||
data-sort-email={sortEmail}
|
data-sort-email={sortEmail}
|
||||||
data-sort-rss={sortRss}
|
|
||||||
data-sort-atom={sortAtom}
|
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
@@ -730,6 +642,12 @@ app.get("/", async (c) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{feed.pendingConfirmation && (
|
||||||
|
<ConfirmationPill feedId={feed.id} />
|
||||||
|
)}
|
||||||
|
{feed.hasNativeFeed && (
|
||||||
|
<NativeFeedPill feedId={feed.id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -739,10 +657,7 @@ app.get("/", async (c) => {
|
|||||||
<CopyFieldInline value={emailAddress} />
|
<CopyFieldInline value={emailAddress} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<CopyFieldInline value={rssUrl} />
|
<FeedFormats feedId={feed.id} env={env} compact />
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<CopyFieldInline value={atomUrl} />
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{feed.expires_at ? (
|
{feed.expires_at ? (
|
||||||
@@ -822,9 +737,7 @@ app.get("/", async (c) => {
|
|||||||
|
|
||||||
<ul class="feed-list">
|
<ul class="feed-list">
|
||||||
{feedsWithConfig.map((feed) => {
|
{feedsWithConfig.map((feed) => {
|
||||||
const emailAddress = feedEmailAddress(feed.id, env);
|
const emailAddress = feedEmailAddress(feed.mailbox_id, env);
|
||||||
const rssUrl = feedRssUrl(feed.id, env);
|
|
||||||
const atomUrl = feedAtomUrl(feed.id, env);
|
|
||||||
const titleDisplay = clampText(feed.title, 140);
|
const titleDisplay = clampText(feed.title, 140);
|
||||||
const titleHover = clampText(feed.title, 1000);
|
const titleHover = clampText(feed.title, 1000);
|
||||||
const descDisplay = clampText(feed.description || "", 240);
|
const descDisplay = clampText(feed.description || "", 240);
|
||||||
@@ -856,6 +769,12 @@ app.get("/", async (c) => {
|
|||||||
{feed.expires_at && (
|
{feed.expires_at && (
|
||||||
<ExpiryBadge expiresAt={feed.expires_at} />
|
<ExpiryBadge expiresAt={feed.expires_at} />
|
||||||
)}
|
)}
|
||||||
|
{feed.pendingConfirmation && (
|
||||||
|
<ConfirmationPill feedId={feed.id} />
|
||||||
|
)}
|
||||||
|
{feed.hasNativeFeed && (
|
||||||
|
<NativeFeedPill feedId={feed.id} />
|
||||||
|
)}
|
||||||
{feed.description && (
|
{feed.description && (
|
||||||
<p class="feed-description">
|
<p class="feed-description">
|
||||||
<span title={descHover}>{descDisplay}</span>
|
<span title={descHover}>{descDisplay}</span>
|
||||||
@@ -880,38 +799,7 @@ app.get("/", async (c) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="copyable">
|
<FeedFormats feedId={feed.id} env={env} />
|
||||||
<span class="copyable-label">RSS Feed:</span>
|
|
||||||
<div class="copyable-content">
|
|
||||||
<span
|
|
||||||
class="copyable-value"
|
|
||||||
data-copy={rssUrl}
|
|
||||||
title={rssUrl}
|
|
||||||
>
|
|
||||||
{rssUrl}
|
|
||||||
</span>
|
|
||||||
<div class="copy-icon-container">
|
|
||||||
<CopyIcon />
|
|
||||||
<CheckIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="copyable">
|
|
||||||
<span class="copyable-label">Atom Feed:</span>
|
|
||||||
<div class="copyable-content">
|
|
||||||
<span
|
|
||||||
class="copyable-value"
|
|
||||||
data-copy={atomUrl}
|
|
||||||
title={atomUrl}
|
|
||||||
>
|
|
||||||
{atomUrl}
|
|
||||||
</span>
|
|
||||||
<div class="copy-icon-container">
|
|
||||||
<CopyIcon />
|
|
||||||
<CheckIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feed-buttons">
|
<div class="feed-buttons">
|
||||||
@@ -975,6 +863,9 @@ app.get("/", async (c) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// OPML export (admin-protected)
|
||||||
|
app.get("/opml", handleOpml);
|
||||||
|
|
||||||
// Mount sub-routers
|
// Mount sub-routers
|
||||||
app.route("/feeds", feedsRouter);
|
app.route("/feeds", feedsRouter);
|
||||||
app.route("/", emailsRouter);
|
app.route("/", emailsRouter);
|
||||||
|
|||||||
+163
-68
@@ -1,7 +1,16 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { Env, EmailMetadata } from "../../types";
|
import { Env, EmailMetadata } from "../../types";
|
||||||
import { logger } from "../../infrastructure/logger";
|
import { logger } from "../../infrastructure/logger";
|
||||||
import { Layout, clampText } from "./ui";
|
import {
|
||||||
|
Layout,
|
||||||
|
clampText,
|
||||||
|
CopyIcon,
|
||||||
|
CheckIcon,
|
||||||
|
FeedFormats,
|
||||||
|
ExpiryBadge,
|
||||||
|
NativeFeeds,
|
||||||
|
} from "./ui";
|
||||||
|
import { unionNativeFeeds } from "../../domain/native-feed";
|
||||||
import {
|
import {
|
||||||
deleteAttachmentsForEmails,
|
deleteAttachmentsForEmails,
|
||||||
deleteKeysWithConcurrency,
|
deleteKeysWithConcurrency,
|
||||||
@@ -9,10 +18,9 @@ import {
|
|||||||
import { FeedRepository } from "../../infrastructure/feed-repository";
|
import { FeedRepository } from "../../infrastructure/feed-repository";
|
||||||
import { FeedId } from "../../domain/value-objects/feed-id";
|
import { FeedId } from "../../domain/value-objects/feed-id";
|
||||||
import {
|
import {
|
||||||
feedRssUrl,
|
|
||||||
feedAtomUrl,
|
|
||||||
feedEmailAddress,
|
feedEmailAddress,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
entryPath,
|
||||||
} from "../../infrastructure/urls";
|
} from "../../infrastructure/urls";
|
||||||
import { processEmailContent } from "../../infrastructure/html-processor";
|
import { processEmailContent } from "../../infrastructure/html-processor";
|
||||||
import { formatBytes } from "../../domain/format";
|
import { formatBytes } from "../../domain/format";
|
||||||
@@ -24,41 +32,6 @@ type AppEnv = { Bindings: Env };
|
|||||||
|
|
||||||
export const emailsRouter = new Hono<AppEnv>();
|
export const emailsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
// ── Shared SVG icons ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const CopyIcon = () => (
|
|
||||||
<svg
|
|
||||||
class="copy-icon copy-icon-original"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CheckIcon = () => (
|
|
||||||
<svg
|
|
||||||
class="copy-icon copy-icon-success"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M20 6L9 17l-5-5"></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
type CopyFieldProps = {
|
type CopyFieldProps = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -169,9 +142,8 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
return c.text("Feed not found", 404);
|
return c.text("Feed not found", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailAddress = feedEmailAddress(feedId, env);
|
const nativeFeeds = unionNativeFeeds(feedMetadata.nativeFeeds);
|
||||||
const rssUrl = feedRssUrl(feedId, env);
|
const emailAddress = feedEmailAddress(feedConfig.mailbox_id, env);
|
||||||
const atomUrl = feedAtomUrl(feedId, env);
|
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${feedConfig.title} - Emails`}>
|
<Layout title={`${feedConfig.title} - Emails`}>
|
||||||
@@ -188,37 +160,56 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Feed Details</h2>
|
{feedConfig.expires_at && (
|
||||||
<div>
|
<div class="feed-header">
|
||||||
<CopyField label="Email Address:" value={emailAddress} />
|
<ExpiryBadge expiresAt={feedConfig.expires_at} />
|
||||||
<CopyField label="RSS Feed:" value={rssUrl} />
|
|
||||||
<CopyField label="Atom Feed:" value={atomUrl} />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="feed-validate">
|
)}
|
||||||
<a
|
<CopyField label="Email:" value={emailAddress} />
|
||||||
href={`https://validator.w3.org/feed/check.cgi?url=${encodeURIComponent(atomUrl)}`}
|
<FeedFormats feedId={feedId} env={env} />
|
||||||
target="_blank"
|
<NativeFeeds feeds={nativeFeeds} />
|
||||||
rel="noopener noreferrer"
|
</div>
|
||||||
|
|
||||||
|
{feedMetadata.pendingConfirmation && (
|
||||||
|
<div
|
||||||
|
class="confirmation-banner"
|
||||||
|
id="confirmation-banner"
|
||||||
|
data-feed-id={feedId}
|
||||||
>
|
>
|
||||||
<img
|
<span>A subscription-confirmation email was detected.</span>
|
||||||
src="https://validator.w3.org/feed/images/valid-atom.png"
|
<div class="confirmation-banner-actions">
|
||||||
alt="[Valid Atom 1.0]"
|
<button
|
||||||
title="Validate my Atom 1.0 feed"
|
type="button"
|
||||||
/>
|
class="button button-small"
|
||||||
</a>
|
id="confirmation-dismiss"
|
||||||
<a
|
|
||||||
href={`https://validator.w3.org/feed/check.cgi?url=${encodeURIComponent(rssUrl)}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
<img
|
Mark as confirmed
|
||||||
src="https://validator.w3.org/feed/images/valid-rss-rogers.png"
|
</button>
|
||||||
alt="[Valid RSS]"
|
|
||||||
title="Validate my RSS feed"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nativeFeeds.length > 0 && !feedMetadata.nativeFeedDismissed && (
|
||||||
|
<div
|
||||||
|
class="confirmation-banner"
|
||||||
|
id="native-feed-banner"
|
||||||
|
data-feed-id={feedId}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
This newsletter publishes its own feed — subscribe to it directly
|
||||||
|
from "Native feeds" above.
|
||||||
|
</span>
|
||||||
|
<div class="confirmation-banner-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="button button-small"
|
||||||
|
id="native-feed-dismiss"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<h2>
|
<h2>
|
||||||
Emails (
|
Emails (
|
||||||
@@ -354,6 +345,7 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
const attachmentLabel = `${attachmentCount} attachment${
|
const attachmentLabel = `${attachmentCount} attachment${
|
||||||
attachmentCount > 1 ? "s" : ""
|
attachmentCount > 1 ? "s" : ""
|
||||||
}`;
|
}`;
|
||||||
|
const isConfirmation = !!email.confirmation;
|
||||||
const sortSubject = subjectHover.toLowerCase();
|
const sortSubject = subjectHover.toLowerCase();
|
||||||
const sortReceivedAt = String(email.receivedAt);
|
const sortReceivedAt = String(email.receivedAt);
|
||||||
const searchHaystack = clampText(
|
const searchHaystack = clampText(
|
||||||
@@ -400,6 +392,14 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isConfirmation ? (
|
||||||
|
<span
|
||||||
|
class="confirmation-badge"
|
||||||
|
title="Subscription confirmation"
|
||||||
|
>
|
||||||
|
Confirmation
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span class="truncate" title={subjectHover}>
|
<span class="truncate" title={subjectHover}>
|
||||||
{subjectDisplay}
|
{subjectDisplay}
|
||||||
</span>
|
</span>
|
||||||
@@ -466,6 +466,13 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
|
|||||||
if (!emailData) return c.text("Email not found", 404);
|
if (!emailData) return c.text("Email not found", 404);
|
||||||
|
|
||||||
const feedId = repo.feedIdFromEmailKey(emailKey);
|
const feedId = repo.feedIdFromEmailKey(emailKey);
|
||||||
|
const feedConfig = await repo.getConfig(FeedId.unchecked(feedId));
|
||||||
|
if (!feedConfig) return c.text("Feed not found", 404);
|
||||||
|
|
||||||
|
const feedMetadata = await repo.getMetadata(FeedId.unchecked(feedId));
|
||||||
|
const confirmationLinks =
|
||||||
|
feedMetadata?.emails.find((e) => e.key === emailKey)?.confirmation?.links ??
|
||||||
|
[];
|
||||||
// Inline images render in place; only downloadable attachments go in the list.
|
// Inline images render in place; only downloadable attachments go in the list.
|
||||||
const attachments = (emailData.attachments ?? []).filter((a) => !a.inline);
|
const attachments = (emailData.attachments ?? []).filter((a) => !a.inline);
|
||||||
|
|
||||||
@@ -584,10 +591,38 @@ 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 label="To:" value={feedEmailAddress(feedId, env)} />
|
<CopyField
|
||||||
|
label="To:"
|
||||||
|
value={feedEmailAddress(feedConfig.mailbox_id, env)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{confirmationLinks.length > 0 && (
|
||||||
|
<div class="confirmation-section">
|
||||||
|
<h2>Confirm your subscription</h2>
|
||||||
|
<p class="muted">
|
||||||
|
This looks like a subscription-confirmation email. Open the link
|
||||||
|
to confirm.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
class="button confirmation-primary"
|
||||||
|
href={confirmationLinks[0]}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Confirm subscription
|
||||||
|
</a>
|
||||||
|
<div class="confirmation-links">
|
||||||
|
{confirmationLinks.map((link) => (
|
||||||
|
<a href={link} target="_blank" rel="noopener noreferrer">
|
||||||
|
{link}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div class="toggle-view">
|
<div class="toggle-view">
|
||||||
<button
|
<button
|
||||||
id="rendered-button"
|
id="rendered-button"
|
||||||
@@ -599,6 +634,14 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
|
|||||||
<button id="raw-button" class="toggle-button" onclick="showRaw()">
|
<button id="raw-button" class="toggle-button" onclick="showRaw()">
|
||||||
Raw HTML
|
Raw HTML
|
||||||
</button>
|
</button>
|
||||||
|
<a
|
||||||
|
class="toggle-view-link"
|
||||||
|
href={entryPath(feedId, emailData.receivedAt)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Public page ↗
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="email-content">
|
<div class="email-content">
|
||||||
@@ -690,6 +733,58 @@ emailsRouter.post("/emails/:emailKey/delete", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Dismiss confirmation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
emailsRouter.post("/feeds/:feedId/confirmation/dismiss", async (c) => {
|
||||||
|
const env = c.env;
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
const feedId = c.req.param("feedId");
|
||||||
|
const wantsJson = (
|
||||||
|
c.req.header("Accept") ||
|
||||||
|
c.req.header("Content-Type") ||
|
||||||
|
""
|
||||||
|
).includes("application/json");
|
||||||
|
|
||||||
|
const feed = await repo.load(FeedId.unchecked(feedId));
|
||||||
|
if (!feed) {
|
||||||
|
return wantsJson
|
||||||
|
? c.json({ ok: false, error: "Feed not found" }, 404)
|
||||||
|
: c.text("Feed not found", 404);
|
||||||
|
}
|
||||||
|
feed.dismissConfirmation();
|
||||||
|
await repo.saveMetadata(feed);
|
||||||
|
|
||||||
|
return wantsJson
|
||||||
|
? c.json({ ok: true })
|
||||||
|
: c.redirect(`/admin/feeds/${feedId}/emails`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Dismiss native-feed notice ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
emailsRouter.post("/feeds/:feedId/native-feed/dismiss", async (c) => {
|
||||||
|
const env = c.env;
|
||||||
|
const repo = FeedRepository.from(env);
|
||||||
|
const feedId = c.req.param("feedId");
|
||||||
|
const wantsJson = (
|
||||||
|
c.req.header("Accept") ||
|
||||||
|
c.req.header("Content-Type") ||
|
||||||
|
""
|
||||||
|
).includes("application/json");
|
||||||
|
|
||||||
|
const feed = await repo.load(FeedId.unchecked(feedId));
|
||||||
|
if (!feed) {
|
||||||
|
return wantsJson
|
||||||
|
? c.json({ ok: false, error: "Feed not found" }, 404)
|
||||||
|
: c.text("Feed not found", 404);
|
||||||
|
}
|
||||||
|
feed.dismissNativeFeed();
|
||||||
|
await repo.saveMetadata(feed);
|
||||||
|
|
||||||
|
return wantsJson
|
||||||
|
? c.json({ ok: true })
|
||||||
|
: c.redirect(`/admin/feeds/${feedId}/emails`);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Bulk delete emails ────────────────────────────────────────────────────────
|
// ── Bulk delete emails ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
|
emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
let title: string;
|
let title: string;
|
||||||
let description: string | undefined;
|
let description: string | undefined;
|
||||||
let language: string;
|
let language: string;
|
||||||
let view: string;
|
|
||||||
let allowedSenders: string[];
|
let allowedSenders: string[];
|
||||||
let blockedSenders: string[];
|
let blockedSenders: string[];
|
||||||
let lifetimeHoursRaw: string | undefined;
|
let lifetimeHoursRaw: string | undefined;
|
||||||
@@ -81,7 +80,6 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
description =
|
description =
|
||||||
body.description != null ? String(body.description) : undefined;
|
body.description != null ? String(body.description) : undefined;
|
||||||
language = String(body.language ?? "en");
|
language = String(body.language ?? "en");
|
||||||
view = "list";
|
|
||||||
allowedSenders = Array.isArray(body.allowedSenders)
|
allowedSenders = Array.isArray(body.allowedSenders)
|
||||||
? normalizeAllowedSenders(
|
? normalizeAllowedSenders(
|
||||||
(body.allowedSenders as unknown[]).map(String),
|
(body.allowedSenders as unknown[]).map(String),
|
||||||
@@ -99,7 +97,6 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
title = formData.get("title")?.toString() || "";
|
title = formData.get("title")?.toString() || "";
|
||||||
description = formData.get("description")?.toString();
|
description = formData.get("description")?.toString();
|
||||||
language = formData.get("language")?.toString() || "en";
|
language = formData.get("language")?.toString() || "en";
|
||||||
view = formData.get("view")?.toString() === "table" ? "table" : "list";
|
|
||||||
allowedSenders = parseAllowedSenders(
|
allowedSenders = parseAllowedSenders(
|
||||||
formData.get("allowed_senders")?.toString() || "",
|
formData.get("allowed_senders")?.toString() || "",
|
||||||
);
|
);
|
||||||
@@ -121,7 +118,7 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
? parseInt(lifetimeHoursRaw, 10)
|
? parseInt(lifetimeHoursRaw, 10)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const { feedId } = await createFeedRecord(env, {
|
const { feedId, mailboxId } = await createFeedRecord(env, {
|
||||||
title: parsedData.title,
|
title: parsedData.title,
|
||||||
description: parsedData.description,
|
description: parsedData.description,
|
||||||
language: parsedData.language,
|
language: parsedData.language,
|
||||||
@@ -133,12 +130,12 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
if (isJson) {
|
if (isJson) {
|
||||||
return c.json({
|
return c.json({
|
||||||
feedId,
|
feedId,
|
||||||
email: feedEmailAddress(feedId, env),
|
email: feedEmailAddress(mailboxId, env),
|
||||||
feedUrl: feedRssUrl(feedId, env),
|
feedUrl: feedRssUrl(feedId, env),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.redirect(`/admin?view=${view}#your-feeds`);
|
return c.redirect(`/admin/feeds/${feedId}/emails`);
|
||||||
} 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")) {
|
||||||
@@ -272,6 +269,23 @@ feedsRouter.get("/:feedId/edit", async (c) => {
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="sender_in_title"
|
||||||
|
value="true"
|
||||||
|
checked={feedConfig.sender_in_title ?? false}
|
||||||
|
disabled={isExpired}
|
||||||
|
/>
|
||||||
|
Show sender in entry titles
|
||||||
|
</label>
|
||||||
|
<small>
|
||||||
|
Render each entry's title as <code>[Sender] Subject</code> for
|
||||||
|
at-a-glance scanning in your reader.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="lifetime_hours">Lifetime (hours)</label>
|
<label for="lifetime_hours">Lifetime (hours)</label>
|
||||||
<input
|
<input
|
||||||
@@ -325,6 +339,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
|||||||
const blockedSenders = parseAllowedSenders(
|
const blockedSenders = parseAllowedSenders(
|
||||||
formData.get("blocked_senders")?.toString() || "",
|
formData.get("blocked_senders")?.toString() || "",
|
||||||
);
|
);
|
||||||
|
const senderInTitle = formData.get("sender_in_title") === "true";
|
||||||
const lifetimeHoursRaw = formData.get("lifetime_hours")?.toString();
|
const lifetimeHoursRaw = formData.get("lifetime_hours")?.toString();
|
||||||
|
|
||||||
const parsedData = updateFeedSchema.parse({
|
const parsedData = updateFeedSchema.parse({
|
||||||
@@ -341,6 +356,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
|||||||
language: parsedData.language,
|
language: parsedData.language,
|
||||||
allowedSenders: parsedData.allowedSenders,
|
allowedSenders: parsedData.allowedSenders,
|
||||||
blockedSenders: parsedData.blockedSenders,
|
blockedSenders: parsedData.blockedSenders,
|
||||||
|
senderInTitle,
|
||||||
lifetimeHours: lifetimeHoursRaw
|
lifetimeHours: lifetimeHoursRaw
|
||||||
? parseInt(lifetimeHoursRaw, 10)
|
? parseInt(lifetimeHoursRaw, 10)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ 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 { APP_VERSION } from "../../config/version";
|
||||||
import { FAVICON_PATH } from "../favicon";
|
import { FAVICON_PATH } from "../favicon";
|
||||||
|
import { Env } from "../../types";
|
||||||
|
import type { NativeFeed } from "../../types";
|
||||||
|
import {
|
||||||
|
feedFormatUrl,
|
||||||
|
feedValidatorUrl,
|
||||||
|
type FeedFormat,
|
||||||
|
} from "../../infrastructure/urls";
|
||||||
|
|
||||||
const designSystem = [
|
const designSystem = [
|
||||||
variablesCss,
|
variablesCss,
|
||||||
@@ -71,6 +79,10 @@ export const Layout = ({ title, label = "admin", children }: LayoutProps) => {
|
|||||||
>
|
>
|
||||||
♥ Sponsor
|
♥ Sponsor
|
||||||
</a>
|
</a>
|
||||||
|
<span class="site-footer-sep" aria-hidden="true">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<span class="site-footer-version">v{APP_VERSION}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -89,3 +101,227 @@ export function clampText(value: string, maxLen: number): string {
|
|||||||
}
|
}
|
||||||
return `${raw.slice(0, maxLen - 3).trimEnd()}...`;
|
return `${raw.slice(0, maxLen - 3).trimEnd()}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared SVG icons ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CopyIcon = () => (
|
||||||
|
<svg
|
||||||
|
class="copy-icon copy-icon-original"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CheckIcon = () => (
|
||||||
|
<svg
|
||||||
|
class="copy-icon copy-icon-success"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M20 6L9 17l-5-5"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const OpenIcon = () => (
|
||||||
|
<svg
|
||||||
|
class="chip-icon"
|
||||||
|
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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||||
|
<polyline points="15 3 21 3 21 9"></polyline>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ValidateIcon = () => (
|
||||||
|
<svg
|
||||||
|
class="chip-icon"
|
||||||
|
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="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Feed format chips ("Subscribe" block) ─────────────────────────────────────
|
||||||
|
|
||||||
|
const FORMAT_LABELS: Record<FeedFormat, string> = {
|
||||||
|
rss: "RSS",
|
||||||
|
atom: "Atom",
|
||||||
|
json: "JSON",
|
||||||
|
};
|
||||||
|
|
||||||
|
// One copyable feed chip: copy + open, plus an optional validate action.
|
||||||
|
// Shared by the KTN "Subscribe" formats and the detected native feeds, so the
|
||||||
|
// copy-script markup (`copyable-value`/`data-copy`) stays identical in one place.
|
||||||
|
const FeedChip = ({
|
||||||
|
label,
|
||||||
|
format,
|
||||||
|
url,
|
||||||
|
validateUrl,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
format: FeedFormat;
|
||||||
|
url: string;
|
||||||
|
validateUrl?: string;
|
||||||
|
}) => (
|
||||||
|
<div class="format-chip" data-format={format}>
|
||||||
|
<span class="format-chip-label">{label}</span>
|
||||||
|
<span class="format-chip-actions">
|
||||||
|
<span class="copyable copyable-chip">
|
||||||
|
<span
|
||||||
|
class="copyable-content"
|
||||||
|
title={`Copy ${label} feed URL`}
|
||||||
|
aria-label={`Copy ${label} feed URL`}
|
||||||
|
>
|
||||||
|
<span class="copyable-value" data-copy={url} hidden></span>
|
||||||
|
<span class="copy-icon-container">
|
||||||
|
<CopyIcon />
|
||||||
|
<CheckIcon />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
class="chip-action"
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`Open ${label} feed in a new tab`}
|
||||||
|
aria-label={`Open ${label} feed in a new tab`}
|
||||||
|
>
|
||||||
|
<OpenIcon />
|
||||||
|
</a>
|
||||||
|
{validateUrl && (
|
||||||
|
<a
|
||||||
|
class="chip-action"
|
||||||
|
href={validateUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={`Validate ${label} feed`}
|
||||||
|
aria-label={`Validate ${label} feed`}
|
||||||
|
>
|
||||||
|
<ValidateIcon />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FormatChip = ({
|
||||||
|
format,
|
||||||
|
feedId,
|
||||||
|
env,
|
||||||
|
}: {
|
||||||
|
format: FeedFormat;
|
||||||
|
feedId: string;
|
||||||
|
env: Env;
|
||||||
|
}) => (
|
||||||
|
<FeedChip
|
||||||
|
label={FORMAT_LABELS[format]}
|
||||||
|
format={format}
|
||||||
|
url={feedFormatUrl(format, feedId, env)}
|
||||||
|
validateUrl={feedValidatorUrl(format, feedId, env)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FeedFormats = ({
|
||||||
|
feedId,
|
||||||
|
env,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
feedId: string;
|
||||||
|
env: Env;
|
||||||
|
compact?: boolean;
|
||||||
|
}) => (
|
||||||
|
<div class={`feed-formats${compact ? " feed-formats-compact" : ""}`}>
|
||||||
|
{!compact && <span class="feed-formats-label">Subscribe</span>}
|
||||||
|
<div class="feed-formats-chips">
|
||||||
|
<FormatChip format="rss" feedId={feedId} env={env} />
|
||||||
|
<FormatChip format="atom" feedId={feedId} env={env} />
|
||||||
|
<FormatChip format="json" feedId={feedId} env={env} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Native feed chips ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const NativeFeeds = ({ feeds }: { feeds: NativeFeed[] }) => {
|
||||||
|
if (feeds.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div class="feed-formats native-feeds">
|
||||||
|
<span class="feed-formats-label">Native feeds</span>
|
||||||
|
<div class="feed-formats-chips">
|
||||||
|
{feeds.map((feed) => (
|
||||||
|
<FeedChip
|
||||||
|
key={feed.url}
|
||||||
|
label={FORMAT_LABELS[feed.type]}
|
||||||
|
format={feed.type}
|
||||||
|
url={feed.url}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Expiry pill ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExpiryBadge = ({ expiresAt }: { expiresAt: number }) => {
|
||||||
|
const { label, expired } = formatExpiry(expiresAt);
|
||||||
|
return (
|
||||||
|
<span class={`pill ${expired ? "pill-expired" : "pill-expiry"}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Hono } from "hono";
|
|||||||
import { apiApp } from "./index";
|
import { apiApp } from "./index";
|
||||||
import { createMockEnv } from "../../test/setup";
|
import { createMockEnv } from "../../test/setup";
|
||||||
import { Env } from "../../types";
|
import { Env } from "../../types";
|
||||||
|
import { FeedRepository } from "../../infrastructure/feed-repository";
|
||||||
|
import { FeedId } from "../../domain/value-objects/feed-id";
|
||||||
|
|
||||||
const PASSWORD = "test-password";
|
const PASSWORD = "test-password";
|
||||||
const authHeaders = { Authorization: `Bearer ${PASSWORD}` };
|
const authHeaders = { Authorization: `Bearer ${PASSWORD}` };
|
||||||
@@ -143,6 +145,40 @@ describe("REST API (/api/v1)", () => {
|
|||||||
expect(afterList.feeds.map((f) => f.id)).not.toContain(created.id);
|
expect(afterList.feeds.map((f) => f.id)).not.toContain(created.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults senderInTitle to false and lets it be set on create and update", async () => {
|
||||||
|
const createRes = await request("/api/v1/feeds", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Title Feed" }),
|
||||||
|
});
|
||||||
|
const created = (await createRes.json()) as {
|
||||||
|
id: string;
|
||||||
|
senderInTitle: boolean;
|
||||||
|
};
|
||||||
|
expect(created.senderInTitle).toBe(false);
|
||||||
|
|
||||||
|
const setRes = await request("/api/v1/feeds", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Prefixed Feed", senderInTitle: true }),
|
||||||
|
});
|
||||||
|
const set = (await setRes.json()) as {
|
||||||
|
id: string;
|
||||||
|
senderInTitle: boolean;
|
||||||
|
};
|
||||||
|
expect(set.senderInTitle).toBe(true);
|
||||||
|
|
||||||
|
const patchRes = await request(`/api/v1/feeds/${set.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ senderInTitle: false }),
|
||||||
|
});
|
||||||
|
expect(patchRes.status).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await patchRes.json()) as { senderInTitle: boolean },
|
||||||
|
).toMatchObject({ senderInTitle: false });
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 400 for an invalid create body", async () => {
|
it("returns 400 for an invalid create body", async () => {
|
||||||
const res = await request("/api/v1/feeds", {
|
const res = await request("/api/v1/feeds", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -178,6 +214,65 @@ describe("REST API (/api/v1)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("nativeFeeds field", () => {
|
||||||
|
it("returns nativeFeeds as empty array for a brand-new feed", async () => {
|
||||||
|
const feedId = await createFeed("Native Feed Test");
|
||||||
|
const res = await request(`/api/v1/feeds/${feedId}`, {
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { nativeFeeds: unknown };
|
||||||
|
expect(body.nativeFeeds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nativeFeeds populated when the feed metadata has native feeds", async () => {
|
||||||
|
const feedId = await createFeed("Native Feed With Data");
|
||||||
|
const id = FeedId.unchecked(feedId);
|
||||||
|
const repo = FeedRepository.from(mockEnv);
|
||||||
|
const feed = await repo.load(id);
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
const receivedAt = Date.now();
|
||||||
|
feed!.ingest(
|
||||||
|
{
|
||||||
|
key: `feed:${feedId}:email:${receivedAt}`,
|
||||||
|
subject: "Newsletter",
|
||||||
|
receivedAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxBytes: 1e9,
|
||||||
|
nativeFeeds: {
|
||||||
|
senderKey: "author@blog.example.com",
|
||||||
|
feeds: [{ url: "https://blog.example.com/feed.xml", type: "rss" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await repo.save(feed!);
|
||||||
|
|
||||||
|
const res = await request(`/api/v1/feeds/${feedId}`, {
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
nativeFeeds: { url: string; type: string }[];
|
||||||
|
};
|
||||||
|
expect(body.nativeFeeds).toEqual([
|
||||||
|
{ url: "https://blog.example.com/feed.xml", type: "rss" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH response also includes nativeFeeds", async () => {
|
||||||
|
const feedId = await createFeed("Patch Native Feed Test");
|
||||||
|
const res = await request(`/api/v1/feeds/${feedId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Updated Title" }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { nativeFeeds: unknown };
|
||||||
|
expect(body.nativeFeeds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Emails", () => {
|
describe("Emails", () => {
|
||||||
it("lists, reads and deletes an email", async () => {
|
it("lists, reads and deletes an email", async () => {
|
||||||
const feedId = await createFeed();
|
const feedId = await createFeed();
|
||||||
@@ -261,10 +356,12 @@ describe("REST API (/api/v1)", () => {
|
|||||||
feeds_created: number;
|
feeds_created: number;
|
||||||
active_feeds: number;
|
active_feeds: number;
|
||||||
attachments_enabled: boolean;
|
attachments_enabled: boolean;
|
||||||
|
version: string;
|
||||||
};
|
};
|
||||||
expect(stats.feeds_created).toBeGreaterThanOrEqual(1);
|
expect(stats.feeds_created).toBeGreaterThanOrEqual(1);
|
||||||
expect(stats.active_feeds).toBeGreaterThanOrEqual(1);
|
expect(stats.active_feeds).toBeGreaterThanOrEqual(1);
|
||||||
expect(typeof stats.attachments_enabled).toBe("boolean");
|
expect(typeof stats.attachments_enabled).toBe("boolean");
|
||||||
|
expect(stats.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+24
-6
@@ -1,7 +1,8 @@
|
|||||||
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { Scalar } from "@scalar/hono-api-reference";
|
import { Scalar } from "@scalar/hono-api-reference";
|
||||||
import { Env, FeedConfig } from "../../types";
|
import { Env, FeedConfig, NativeFeed } from "../../types";
|
||||||
|
import { unionNativeFeeds } from "../../domain/native-feed";
|
||||||
import { apiAuthMiddleware } from "../../infrastructure/auth";
|
import { apiAuthMiddleware } from "../../infrastructure/auth";
|
||||||
import {
|
import {
|
||||||
createFeedRecord,
|
createFeedRecord,
|
||||||
@@ -51,6 +52,7 @@ function toFeed(
|
|||||||
config: FeedConfig,
|
config: FeedConfig,
|
||||||
emailCount: number,
|
emailCount: number,
|
||||||
env: Env,
|
env: Env,
|
||||||
|
nativeFeeds: NativeFeed[],
|
||||||
): z.infer<typeof FeedSchema> {
|
): z.infer<typeof FeedSchema> {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -59,13 +61,15 @@ function toFeed(
|
|||||||
language: config.language,
|
language: config.language,
|
||||||
allowedSenders: config.allowed_senders ?? [],
|
allowedSenders: config.allowed_senders ?? [],
|
||||||
blockedSenders: config.blocked_senders ?? [],
|
blockedSenders: config.blocked_senders ?? [],
|
||||||
|
senderInTitle: config.sender_in_title ?? false,
|
||||||
createdAt: config.created_at,
|
createdAt: config.created_at,
|
||||||
updatedAt: config.updated_at,
|
updatedAt: config.updated_at,
|
||||||
expiresAt: config.expires_at,
|
expiresAt: config.expires_at,
|
||||||
emailCount,
|
emailCount,
|
||||||
emailAddress: feedEmailAddress(id, env),
|
emailAddress: feedEmailAddress(config.mailbox_id, env),
|
||||||
rssUrl: feedRssUrl(id, env),
|
rssUrl: feedRssUrl(id, env),
|
||||||
atomUrl: feedAtomUrl(id, env),
|
atomUrl: feedAtomUrl(id, env),
|
||||||
|
nativeFeeds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +121,7 @@ apiApp.openapi(
|
|||||||
title: f.title,
|
title: f.title,
|
||||||
description: f.description,
|
description: f.description,
|
||||||
expiresAt: f.expires_at,
|
expiresAt: f.expires_at,
|
||||||
emailAddress: feedEmailAddress(f.id, env),
|
emailAddress: feedEmailAddress(f.mailbox_id, env),
|
||||||
rssUrl: feedRssUrl(f.id, env),
|
rssUrl: feedRssUrl(f.id, env),
|
||||||
atomUrl: feedAtomUrl(f.id, env),
|
atomUrl: feedAtomUrl(f.id, env),
|
||||||
})),
|
})),
|
||||||
@@ -152,9 +156,10 @@ apiApp.openapi(
|
|||||||
language: body.language,
|
language: body.language,
|
||||||
allowedSenders: normalizeSenders(body.allowedSenders) ?? [],
|
allowedSenders: normalizeSenders(body.allowedSenders) ?? [],
|
||||||
blockedSenders: normalizeSenders(body.blockedSenders) ?? [],
|
blockedSenders: normalizeSenders(body.blockedSenders) ?? [],
|
||||||
|
senderInTitle: body.senderInTitle,
|
||||||
lifetimeHours: body.lifetimeHours,
|
lifetimeHours: body.lifetimeHours,
|
||||||
});
|
});
|
||||||
return c.json(toFeed(feedId, config, 0, env), 201);
|
return c.json(toFeed(feedId, config, 0, env, []), 201);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -181,7 +186,13 @@ apiApp.openapi(
|
|||||||
if (!config) return c.json({ error: "Feed not found" }, 404);
|
if (!config) return c.json({ error: "Feed not found" }, 404);
|
||||||
const metadata = await repo.getMetadata(id);
|
const metadata = await repo.getMetadata(id);
|
||||||
return c.json(
|
return c.json(
|
||||||
toFeed(feedId, config, metadata?.emails.length ?? 0, env),
|
toFeed(
|
||||||
|
feedId,
|
||||||
|
config,
|
||||||
|
metadata?.emails.length ?? 0,
|
||||||
|
env,
|
||||||
|
unionNativeFeeds(metadata?.nativeFeeds),
|
||||||
|
),
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -217,6 +228,7 @@ apiApp.openapi(
|
|||||||
language: body.language,
|
language: body.language,
|
||||||
allowedSenders: normalizeSenders(body.allowedSenders),
|
allowedSenders: normalizeSenders(body.allowedSenders),
|
||||||
blockedSenders: normalizeSenders(body.blockedSenders),
|
blockedSenders: normalizeSenders(body.blockedSenders),
|
||||||
|
senderInTitle: body.senderInTitle,
|
||||||
lifetimeHours: body.lifetimeHours,
|
lifetimeHours: body.lifetimeHours,
|
||||||
});
|
});
|
||||||
if (result.status === "not_found")
|
if (result.status === "not_found")
|
||||||
@@ -225,7 +237,13 @@ apiApp.openapi(
|
|||||||
return c.json({ error: "Feed has expired and cannot be modified" }, 409);
|
return c.json({ error: "Feed has expired and cannot be modified" }, 409);
|
||||||
const metadata = await FeedRepository.from(env).getMetadata(id);
|
const metadata = await FeedRepository.from(env).getMetadata(id);
|
||||||
return c.json(
|
return c.json(
|
||||||
toFeed(feedId, result.config, metadata?.emails.length ?? 0, env),
|
toFeed(
|
||||||
|
feedId,
|
||||||
|
result.config,
|
||||||
|
metadata?.emails.length ?? 0,
|
||||||
|
env,
|
||||||
|
unionNativeFeeds(metadata?.nativeFeeds),
|
||||||
|
),
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export const FeedIdParam = z.object({
|
|||||||
.min(1)
|
.min(1)
|
||||||
.openapi({
|
.openapi({
|
||||||
param: { name: "feedId", in: "path" },
|
param: { name: "feedId", in: "path" },
|
||||||
example: "happy-otter-1234",
|
description:
|
||||||
|
"The feed's opaque id (the read id in /rss/:feedId), not the inbound address.",
|
||||||
|
example: "kZ8xQ2pLm4nR7vT1wB9yJc",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,6 +39,10 @@ export const FeedCreateSchema = z
|
|||||||
language: z.string().optional().default("en"),
|
language: z.string().optional().default("en"),
|
||||||
allowedSenders: z.array(z.string()).optional().default([]),
|
allowedSenders: z.array(z.string()).optional().default([]),
|
||||||
blockedSenders: z.array(z.string()).optional().default([]),
|
blockedSenders: z.array(z.string()).optional().default([]),
|
||||||
|
senderInTitle: z.boolean().optional().openapi({
|
||||||
|
description:
|
||||||
|
"Render entry titles as `[Sender] Subject` in the feed output.",
|
||||||
|
}),
|
||||||
lifetimeHours: z.number().int().positive().optional().openapi({
|
lifetimeHours: z.number().int().positive().optional().openapi({
|
||||||
description:
|
description:
|
||||||
"Hours until the feed expires. Ignored when the server enforces a fixed FEED_TTL_HOURS.",
|
"Hours until the feed expires. Ignored when the server enforces a fixed FEED_TTL_HOURS.",
|
||||||
@@ -51,6 +57,10 @@ export const FeedUpdateSchema = z
|
|||||||
language: z.string().optional(),
|
language: z.string().optional(),
|
||||||
allowedSenders: z.array(z.string()).optional(),
|
allowedSenders: z.array(z.string()).optional(),
|
||||||
blockedSenders: z.array(z.string()).optional(),
|
blockedSenders: z.array(z.string()).optional(),
|
||||||
|
senderInTitle: z.boolean().optional().openapi({
|
||||||
|
description:
|
||||||
|
"Render entry titles as `[Sender] Subject` in the feed output.",
|
||||||
|
}),
|
||||||
lifetimeHours: z.number().int().positive().optional().openapi({
|
lifetimeHours: z.number().int().positive().optional().openapi({
|
||||||
description: "Reset the feed's lifetime to this many hours from now.",
|
description: "Reset the feed's lifetime to this many hours from now.",
|
||||||
}),
|
}),
|
||||||
@@ -81,6 +91,7 @@ export const FeedSchema = z
|
|||||||
language: z.string(),
|
language: z.string(),
|
||||||
allowedSenders: z.array(z.string()),
|
allowedSenders: z.array(z.string()),
|
||||||
blockedSenders: z.array(z.string()),
|
blockedSenders: z.array(z.string()),
|
||||||
|
senderInTitle: z.boolean(),
|
||||||
createdAt: z.number(),
|
createdAt: z.number(),
|
||||||
updatedAt: z.number().optional(),
|
updatedAt: z.number().optional(),
|
||||||
expiresAt: z.number().optional(),
|
expiresAt: z.number().optional(),
|
||||||
@@ -88,6 +99,12 @@ export const FeedSchema = z
|
|||||||
emailAddress: z.string(),
|
emailAddress: z.string(),
|
||||||
rssUrl: z.string(),
|
rssUrl: z.string(),
|
||||||
atomUrl: z.string(),
|
atomUrl: z.string(),
|
||||||
|
nativeFeeds: z.array(
|
||||||
|
z.object({
|
||||||
|
url: z.string(),
|
||||||
|
type: z.enum(["rss", "atom", "json"]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.openapi("Feed");
|
.openapi("Feed");
|
||||||
|
|
||||||
@@ -138,10 +155,14 @@ export const StatsSchema = z
|
|||||||
feeds_deleted: z.number(),
|
feeds_deleted: z.number(),
|
||||||
emails_received: z.number(),
|
emails_received: z.number(),
|
||||||
emails_rejected: z.number(),
|
emails_rejected: z.number(),
|
||||||
|
emails_forwarded: z.number(),
|
||||||
unsubscribes_sent: z.number(),
|
unsubscribes_sent: z.number(),
|
||||||
active_feeds: z.number(),
|
active_feeds: z.number(),
|
||||||
websub_subscriptions_active: z.number(),
|
websub_subscriptions_active: z.number(),
|
||||||
attachments_enabled: z.boolean(),
|
attachments_enabled: z.boolean(),
|
||||||
|
version: z.string().openapi({
|
||||||
|
description: "Running app version (package.json), inlined at build time.",
|
||||||
|
}),
|
||||||
last_email_at: z.string().optional(),
|
last_email_at: z.string().optional(),
|
||||||
last_feed_created_at: z.string().optional(),
|
last_feed_created_at: z.string().optional(),
|
||||||
first_seen: z.string().optional(),
|
first_seen: z.string().optional(),
|
||||||
|
|||||||
+122
-2
@@ -47,6 +47,11 @@ describe("Atom Feed Route", () => {
|
|||||||
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
||||||
expect(res.headers.get("Cache-Control")).toBe("max-age=1800");
|
expect(res.headers.get("Cache-Control")).toBe("max-age=1800");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets X-Robots-Tag: noindex", async () => {
|
||||||
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
||||||
|
expect(res.headers.get("X-Robots-Tag")).toBe("noindex");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("valid feed with emails", () => {
|
describe("valid feed with emails", () => {
|
||||||
@@ -112,10 +117,12 @@ describe("Atom Feed Route", () => {
|
|||||||
expect(body).toContain("Atom Test Feed");
|
expect(body).toContain("Atom Test Feed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("self-link points to atom URL", async () => {
|
it("self-link uses the configured domain, not the request host", async () => {
|
||||||
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
|
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
|
||||||
const body = await res.text();
|
const body = await res.text();
|
||||||
expect(body).toContain(`/atom/${FEED_ID}`);
|
expect(body).toContain(
|
||||||
|
`rel="self" href="https://${mockEnv.DOMAIN}/atom/${FEED_ID}"`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Link header advertises hub and self for WebSub discovery", async () => {
|
it("Link header advertises hub and self for WebSub discovery", async () => {
|
||||||
@@ -139,4 +146,117 @@ describe("Atom Feed Route", () => {
|
|||||||
expect(body).toContain('xmlns="http://www.w3.org/2005/Atom"');
|
expect(body).toContain('xmlns="http://www.w3.org/2005/Atom"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("conditional GET (ETag + Last-Modified)", () => {
|
||||||
|
const FEED_ID = "test-feed-atom-cget";
|
||||||
|
const EMAIL_RECEIVED_AT = 1700000001000;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const emailKey = `feed:${FEED_ID}:${EMAIL_RECEIVED_AT}`;
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
emailKey,
|
||||||
|
JSON.stringify({
|
||||||
|
subject: "Atom Subject",
|
||||||
|
from: "Sender <sender@example.com>",
|
||||||
|
content: "<p>Body</p>",
|
||||||
|
receivedAt: EMAIL_RECEIVED_AT,
|
||||||
|
headers: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${FEED_ID}:metadata`,
|
||||||
|
JSON.stringify({
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
key: emailKey,
|
||||||
|
subject: "Atom Subject",
|
||||||
|
receivedAt: EMAIL_RECEIVED_AT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${FEED_ID}:config`,
|
||||||
|
JSON.stringify({
|
||||||
|
title: "Atom Cget Feed",
|
||||||
|
language: "en",
|
||||||
|
created_at: 1700000000000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first GET returns 200 with ETag and Last-Modified headers", async () => {
|
||||||
|
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("ETag")).toBeTruthy();
|
||||||
|
expect(res.headers.get("Last-Modified")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET with matching If-None-Match returns 304 with empty body", async () => {
|
||||||
|
const first = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
|
||||||
|
const etag = first.headers.get("ETag")!;
|
||||||
|
|
||||||
|
const res = await testApp.request(
|
||||||
|
`/${FEED_ID}`,
|
||||||
|
{ headers: { "If-None-Match": etag } },
|
||||||
|
mockEnv,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(304);
|
||||||
|
expect(await res.text()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET with If-Modified-Since in the future returns 304", async () => {
|
||||||
|
const future = new Date(EMAIL_RECEIVED_AT + 1000).toUTCString();
|
||||||
|
const res = await testApp.request(
|
||||||
|
`/${FEED_ID}`,
|
||||||
|
{ headers: { "If-Modified-Since": future } },
|
||||||
|
mockEnv,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(304);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stale If-None-Match after new email results in 200", async () => {
|
||||||
|
const first = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
|
||||||
|
const oldEtag = first.headers.get("ETag")!;
|
||||||
|
|
||||||
|
const newReceivedAt = EMAIL_RECEIVED_AT + 5000;
|
||||||
|
const newEmailKey = `feed:${FEED_ID}:${newReceivedAt}`;
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
newEmailKey,
|
||||||
|
JSON.stringify({
|
||||||
|
subject: "Newer Atom Email",
|
||||||
|
from: "Sender <sender@example.com>",
|
||||||
|
content: "<p>New body</p>",
|
||||||
|
receivedAt: newReceivedAt,
|
||||||
|
headers: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${FEED_ID}:metadata`,
|
||||||
|
JSON.stringify({
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
key: newEmailKey,
|
||||||
|
subject: "Newer Atom Email",
|
||||||
|
receivedAt: newReceivedAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `feed:${FEED_ID}:${EMAIL_RECEIVED_AT}`,
|
||||||
|
subject: "Atom Subject",
|
||||||
|
receivedAt: EMAIL_RECEIVED_AT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await testApp.request(
|
||||||
|
`/${FEED_ID}`,
|
||||||
|
{ headers: { "If-None-Match": oldEtag } },
|
||||||
|
mockEnv,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const newEtag = res.headers.get("ETag");
|
||||||
|
expect(newEtag).not.toBe(oldEtag);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-1
@@ -5,6 +5,11 @@ import { fetchFeedData } from "../application/feed-fetcher";
|
|||||||
import { baseUrl, feedAtomUrl } from "../infrastructure/urls";
|
import { baseUrl, feedAtomUrl } from "../infrastructure/urls";
|
||||||
import { isExpired } from "../domain/feed";
|
import { isExpired } from "../domain/feed";
|
||||||
import { FeedId } from "../domain/value-objects/feed-id";
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import {
|
||||||
|
computeFeedValidators,
|
||||||
|
isNotModified,
|
||||||
|
notModifiedResponse,
|
||||||
|
} from "../infrastructure/http-cache";
|
||||||
|
|
||||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
@@ -21,8 +26,19 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
return new Response("Feed has expired", { status: 410 });
|
return new Response("Feed has expired", { status: 410 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validators = computeFeedValidators(
|
||||||
|
"atom",
|
||||||
|
feedId,
|
||||||
|
feedData.feedConfig,
|
||||||
|
feedData.emails,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isNotModified(c.req.raw, validators)) {
|
||||||
|
return notModifiedResponse(validators);
|
||||||
|
}
|
||||||
|
|
||||||
const base = baseUrl(c.env);
|
const base = baseUrl(c.env);
|
||||||
const selfUrl = new URL(c.req.url).origin + `/atom/${feedId}`;
|
const selfUrl = feedAtomUrl(feedId, c.env);
|
||||||
const atomXml = generateAtomFeed(
|
const atomXml = generateAtomFeed(
|
||||||
feedData.feedConfig,
|
feedData.feedConfig,
|
||||||
feedData.emails,
|
feedData.emails,
|
||||||
@@ -40,7 +56,10 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/atom+xml",
|
"Content-Type": "application/atom+xml",
|
||||||
"Cache-Control": "max-age=1800",
|
"Cache-Control": "max-age=1800",
|
||||||
|
"X-Robots-Tag": "noindex",
|
||||||
Link: linkHeader,
|
Link: linkHeader,
|
||||||
|
ETag: validators.etag,
|
||||||
|
"Last-Modified": validators.lastModified,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -170,4 +170,11 @@ describe("GET /entries/:feedId/:entryId", () => {
|
|||||||
"default-src 'none'",
|
"default-src 'none'",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets X-Robots-Tag: noindex", async () => {
|
||||||
|
await seedFeed(env);
|
||||||
|
const app = makeApp();
|
||||||
|
const res = await app.request(`/${FEED_ID}/${RECEIVED_AT}`, {}, env as any);
|
||||||
|
expect(res.headers.get("X-Robots-Tag")).toBe("noindex");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-5
@@ -2,6 +2,7 @@ import { Context } from "hono";
|
|||||||
import { html, raw } from "hono/html";
|
import { html, raw } from "hono/html";
|
||||||
import { Env } from "../types";
|
import { Env } from "../types";
|
||||||
import { processEmailContent } from "../infrastructure/html-processor";
|
import { processEmailContent } from "../infrastructure/html-processor";
|
||||||
|
import { EmailAddress } from "../domain/value-objects/email-address";
|
||||||
import { formatBytes } from "../domain/format";
|
import { formatBytes } from "../domain/format";
|
||||||
import { FeedRepository } from "../infrastructure/feed-repository";
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
import { FeedId } from "../domain/value-objects/feed-id";
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
@@ -46,6 +47,14 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
"Content-Security-Policy",
|
"Content-Security-Policy",
|
||||||
"default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'",
|
"default-src 'none'; style-src 'unsafe-inline'; img-src *; frame-src 'none'",
|
||||||
);
|
);
|
||||||
|
c.header("X-Robots-Tag", "noindex");
|
||||||
|
|
||||||
|
const bodyContent = processEmailContent(
|
||||||
|
emailData.content,
|
||||||
|
emailData.attachments,
|
||||||
|
"",
|
||||||
|
EmailAddress.parse(emailData.from)?.siteBaseUrl() ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
// Inline images render in place (cid: refs are rewritten by processEmailContent);
|
// Inline images render in place (cid: refs are rewritten by processEmailContent);
|
||||||
// only genuine, downloadable attachments belong in the list below.
|
// only genuine, downloadable attachments belong in the list below.
|
||||||
@@ -92,11 +101,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
<dt>Date:</dt>
|
<dt>Date:</dt>
|
||||||
<dd>${new Date(emailData.receivedAt).toUTCString()}</dd>
|
<dd>${new Date(emailData.receivedAt).toUTCString()}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="content">
|
<div class="content">${raw(bodyContent)}</div>
|
||||||
${raw(
|
|
||||||
processEmailContent(emailData.content, emailData.attachments),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
${attachmentsSection}
|
${attachmentsSection}
|
||||||
</body>
|
</body>
|
||||||
</html>`,
|
</html>`,
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ describe("GET /files/:attachmentId/:filename", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets X-Robots-Tag: noindex", async () => {
|
||||||
|
const content = new TextEncoder().encode("data").buffer as ArrayBuffer;
|
||||||
|
await mockR2.put("robots-uuid", content, {
|
||||||
|
httpMetadata: { contentType: "application/pdf" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(envWithR2, "/files/robots-uuid/doc.pdf");
|
||||||
|
expect(res.headers.get("X-Robots-Tag")).toBe("noindex");
|
||||||
|
});
|
||||||
|
|
||||||
it("sets Content-Disposition from httpMetadata when present", async () => {
|
it("sets Content-Disposition from httpMetadata when present", async () => {
|
||||||
const content = new TextEncoder().encode("data").buffer as ArrayBuffer;
|
const content = new TextEncoder().encode("data").buffer as ArrayBuffer;
|
||||||
await mockR2.put("disp-uuid", content, {
|
await mockR2.put("disp-uuid", content, {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
object.writeHttpMetadata(headers);
|
object.writeHttpMetadata(headers);
|
||||||
headers.set("etag", object.httpEtag);
|
headers.set("etag", object.httpEtag);
|
||||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
headers.set("X-Robots-Tag", "noindex");
|
||||||
|
|
||||||
if (!headers.get("Content-Disposition")) {
|
if (!headers.get("Content-Disposition")) {
|
||||||
headers.set(
|
headers.set(
|
||||||
|
|||||||
@@ -162,6 +162,11 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
value={stats.emails_rejected}
|
value={stats.emails_rejected}
|
||||||
tone="danger"
|
tone="danger"
|
||||||
/>
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Forwarded (catch-all)"
|
||||||
|
value={stats.emails_forwarded}
|
||||||
|
/>
|
||||||
|
<Stat label="Deduplicated" value={stats.emails_deduplicated} />
|
||||||
<Stat
|
<Stat
|
||||||
label="Acceptance rate"
|
label="Acceptance rate"
|
||||||
value={acceptanceRate}
|
value={acceptanceRate}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import worker from "../index";
|
import worker from "../index";
|
||||||
import { server, createMockEnv, MockR2 } from "../test/setup";
|
import { server, createMockEnv, MockR2, seedInboundIndex } from "../test/setup";
|
||||||
import type { Env } from "../types";
|
import type { Env } from "../types";
|
||||||
import type { ForwardEmailPayload } from "../infrastructure/forwardemail";
|
import type { ForwardEmailPayload } from "../infrastructure/forwardemail";
|
||||||
|
|
||||||
@@ -64,6 +64,7 @@ describe("POST /api/inbound — IP middleware", () => {
|
|||||||
`feed:${VALID_FEED_ID}:config`,
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
JSON.stringify({ allowed_senders: [] }),
|
JSON.stringify({ allowed_senders: [] }),
|
||||||
);
|
);
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when IP is not in the ForwardEmail allowlist", async () => {
|
it("returns 401 when IP is not in the ForwardEmail allowlist", async () => {
|
||||||
@@ -99,9 +100,10 @@ describe("POST /api/inbound — IP middleware", () => {
|
|||||||
describe("POST /api/inbound — handler logic", () => {
|
describe("POST /api/inbound — handler logic", () => {
|
||||||
let env: Env;
|
let env: Env;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
stubForwardEmailIps();
|
stubForwardEmailIps();
|
||||||
env = createMockEnv() as unknown as Env;
|
env = createMockEnv() as unknown as Env;
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 500 on malformed JSON body", async () => {
|
it("returns 500 on malformed JSON body", async () => {
|
||||||
@@ -232,9 +234,10 @@ describe("POST /api/inbound — handler logic", () => {
|
|||||||
describe("POST /api/inbound — attachment upload", () => {
|
describe("POST /api/inbound — attachment upload", () => {
|
||||||
let env: Env;
|
let env: Env;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
stubForwardEmailIps();
|
stubForwardEmailIps();
|
||||||
env = createMockEnv({ withR2: true }) as unknown as Env;
|
env = createMockEnv({ withR2: true }) as unknown as Env;
|
||||||
|
await seedInboundIndex(env, VALID_FEED_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uploads attachments to R2 and records ids in metadata", async () => {
|
it("uploads attachments to R2 and records ids in metadata", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { handle } from "./json";
|
||||||
|
import { createMockEnv } from "../test/setup";
|
||||||
|
import { Env } from "../types";
|
||||||
|
|
||||||
|
describe("JSON Feed Route", () => {
|
||||||
|
let testApp: Hono;
|
||||||
|
let mockEnv: Env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockEnv = createMockEnv() as unknown as Env;
|
||||||
|
testApp = new Hono();
|
||||||
|
testApp.get("/:feedId", handle);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unknown feed", () => {
|
||||||
|
it("returns 404 when no metadata exists in KV", async () => {
|
||||||
|
const res = await testApp.request("/nonexistent-feed", {}, mockEnv);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(await res.text()).toBe("Feed not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("valid feed with no emails", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
"feed:empty-feed:metadata",
|
||||||
|
JSON.stringify({ emails: [] }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with application/feed+json content type", async () => {
|
||||||
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toContain(
|
||||||
|
"application/feed+json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes Cache-Control header", async () => {
|
||||||
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
||||||
|
expect(res.headers.get("Cache-Control")).toBe("max-age=1800");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets X-Robots-Tag: noindex", async () => {
|
||||||
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
||||||
|
expect(res.headers.get("X-Robots-Tag")).toBe("noindex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Link header advertises hub and self", async () => {
|
||||||
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
||||||
|
const link = res.headers.get("Link") ?? "";
|
||||||
|
expect(link).toContain(`rel="hub"`);
|
||||||
|
expect(link).toContain(
|
||||||
|
`<https://${mockEnv.DOMAIN}/json/empty-feed>; rel="self"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("body parses as JSON with jsonfeed version 1.1", async () => {
|
||||||
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
||||||
|
const body = (await res.json()) as { version: string; items: unknown[] };
|
||||||
|
expect(body.version).toBe("https://jsonfeed.org/version/1");
|
||||||
|
expect(Array.isArray(body.items)).toBe(true);
|
||||||
|
expect(body.items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("valid feed with emails", () => {
|
||||||
|
const FEED_ID = "test-feed-json";
|
||||||
|
const EMAIL_RECEIVED_AT = 1700000001000;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const emailKey = `feed:${FEED_ID}:${EMAIL_RECEIVED_AT}`;
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
emailKey,
|
||||||
|
JSON.stringify({
|
||||||
|
subject: "JSON Feed Subject",
|
||||||
|
from: "Sender <sender@example.com>",
|
||||||
|
content: "<p>Body content</p>",
|
||||||
|
receivedAt: EMAIL_RECEIVED_AT,
|
||||||
|
headers: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${FEED_ID}:metadata`,
|
||||||
|
JSON.stringify({
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
key: emailKey,
|
||||||
|
subject: "JSON Feed Subject",
|
||||||
|
receivedAt: EMAIL_RECEIVED_AT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${FEED_ID}:config`,
|
||||||
|
JSON.stringify({
|
||||||
|
title: "My JSON Feed",
|
||||||
|
language: "en",
|
||||||
|
created_at: 1700000000000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with items containing the seeded email", async () => {
|
||||||
|
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
version: string;
|
||||||
|
items: Array<{ title: string }>;
|
||||||
|
};
|
||||||
|
expect(body.version).toBe("https://jsonfeed.org/version/1");
|
||||||
|
expect(Array.isArray(body.items)).toBe(true);
|
||||||
|
expect(body.items).toHaveLength(1);
|
||||||
|
expect(body.items[0].title).toBe("JSON Feed Subject");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("expired feed", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const pastTimestamp = Date.now() - 1000 * 60 * 60 * 24; // 1 day ago
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
"feed:expired-feed:metadata",
|
||||||
|
JSON.stringify({ emails: [] }),
|
||||||
|
);
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
"feed:expired-feed:config",
|
||||||
|
JSON.stringify({
|
||||||
|
title: "Expired Feed",
|
||||||
|
language: "en",
|
||||||
|
created_at: pastTimestamp,
|
||||||
|
expires_at: pastTimestamp,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 410 for expired feed", async () => {
|
||||||
|
const res = await testApp.request("/expired-feed", {}, mockEnv);
|
||||||
|
expect(res.status).toBe(410);
|
||||||
|
expect(await res.text()).toBe("Feed has expired");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
import { Env } from "../types";
|
||||||
|
import { generateJsonFeed } from "../infrastructure/feed-generator";
|
||||||
|
import { fetchFeedData } from "../application/feed-fetcher";
|
||||||
|
import { baseUrl, feedJsonUrl } 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> {
|
||||||
|
try {
|
||||||
|
const feedId = c.req.param("feedId");
|
||||||
|
if (!feedId) {
|
||||||
|
return new Response("Feed ID is required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedData = await fetchFeedData(FeedId.unchecked(feedId), c.env);
|
||||||
|
if (!feedData) {
|
||||||
|
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 selfUrl = feedJsonUrl(feedId, c.env);
|
||||||
|
const jsonFeed = generateJsonFeed(
|
||||||
|
feedData.feedConfig,
|
||||||
|
feedData.emails,
|
||||||
|
base,
|
||||||
|
feedId,
|
||||||
|
selfUrl,
|
||||||
|
);
|
||||||
|
const linkHeader = [
|
||||||
|
`<${base}/hub>; rel="hub"`,
|
||||||
|
`<${selfUrl}>; rel="self"`,
|
||||||
|
].join(", ");
|
||||||
|
|
||||||
|
return new Response(jsonFeed, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/feed+json",
|
||||||
|
"Cache-Control": "max-age=1800",
|
||||||
|
"X-Robots-Tag": "noindex",
|
||||||
|
Link: linkHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating JSON feed:", error);
|
||||||
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import app from "./admin";
|
||||||
|
import { createMockEnv } from "../test/setup";
|
||||||
|
import { Env } from "../types";
|
||||||
|
|
||||||
|
describe("OPML export — GET /admin/opml", () => {
|
||||||
|
let testApp: Hono;
|
||||||
|
let mockEnv: Env;
|
||||||
|
let request: (path: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
let loginAndGetCookie: () => Promise<string>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockEnv = createMockEnv() as unknown as Env;
|
||||||
|
testApp = new Hono();
|
||||||
|
testApp.route("/admin", app);
|
||||||
|
request = (path, init = {}) =>
|
||||||
|
Promise.resolve(testApp.request(path, init, mockEnv));
|
||||||
|
loginAndGetCookie = async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("password", "test-password");
|
||||||
|
const response = await request("/admin/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
const setCookie = response.headers.get("Set-Cookie");
|
||||||
|
expect(setCookie).toBeTruthy();
|
||||||
|
return (setCookie as string).split(";")[0];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 302 redirect to login when not authenticated", async () => {
|
||||||
|
const res = await request("/admin/opml");
|
||||||
|
expect(res.status).toBe(302);
|
||||||
|
expect(res.headers.get("Location")).toBe("/admin/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 200 with OPML content when authenticated", async () => {
|
||||||
|
// Seed two feeds in the registry
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
"feeds:list",
|
||||||
|
JSON.stringify({
|
||||||
|
feeds: [
|
||||||
|
{ id: "feed-abc", title: "My Newsletter", description: "Daily news" },
|
||||||
|
{ id: "feed-xyz", title: "Tech Digest" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const res = await request("/admin/opml", {
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const contentType = res.headers.get("Content-Type") ?? "";
|
||||||
|
expect(contentType).toContain("text/x-opml");
|
||||||
|
expect(res.headers.get("Content-Disposition")).toBe(
|
||||||
|
'attachment; filename="feeds.opml"',
|
||||||
|
);
|
||||||
|
expect(res.headers.get("X-Robots-Tag")).toBe("noindex");
|
||||||
|
|
||||||
|
const body = await res.text();
|
||||||
|
|
||||||
|
// Valid OPML 2.0 structure
|
||||||
|
expect(body).toContain('<?xml version="1.0" encoding="UTF-8"?>');
|
||||||
|
expect(body).toContain('<opml version="2.0">');
|
||||||
|
expect(body).toContain("<head>");
|
||||||
|
expect(body).toContain("<title>kill-the-news feeds</title>");
|
||||||
|
expect(body).toContain("<body>");
|
||||||
|
|
||||||
|
// One outline per feed with correct xmlUrl
|
||||||
|
expect(body).toContain('type="rss"');
|
||||||
|
expect(body).toContain('text="My Newsletter"');
|
||||||
|
expect(body).toContain('title="My Newsletter"');
|
||||||
|
expect(body).toContain('xmlUrl="https://test.getmynews.app/rss/feed-abc"');
|
||||||
|
expect(body).toContain('description="Daily news"');
|
||||||
|
|
||||||
|
expect(body).toContain('text="Tech Digest"');
|
||||||
|
expect(body).toContain('xmlUrl="https://test.getmynews.app/rss/feed-xyz"');
|
||||||
|
|
||||||
|
// feed-xyz has no description — attribute must not appear
|
||||||
|
const feedXyzLine =
|
||||||
|
body.split("\n").find((l) => l.includes("feed-xyz")) ?? "";
|
||||||
|
expect(feedXyzLine).not.toContain("description=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should XML-escape special characters in title and description", async () => {
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
"feeds:list",
|
||||||
|
JSON.stringify({
|
||||||
|
feeds: [
|
||||||
|
{
|
||||||
|
id: "feed-special",
|
||||||
|
title: "News & <Updates>",
|
||||||
|
description: 'Say "hello" & goodbye',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const res = await request("/admin/opml", {
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
|
||||||
|
// Raw special chars must not appear unescaped in attribute values
|
||||||
|
const outlineLine =
|
||||||
|
body.split("\n").find((l) => l.includes("feed-special")) ?? "";
|
||||||
|
expect(outlineLine).toContain("News & <Updates>");
|
||||||
|
expect(outlineLine).toContain("Say "hello" & goodbye");
|
||||||
|
expect(outlineLine).not.toContain('title="News & <');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty body element when there are no feeds", async () => {
|
||||||
|
const authCookie = await loginAndGetCookie();
|
||||||
|
const res = await request("/admin/opml", {
|
||||||
|
headers: {
|
||||||
|
Cookie: authCookie,
|
||||||
|
Origin: "https://test.getmynews.app",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.text();
|
||||||
|
expect(body).toContain("<body>");
|
||||||
|
expect(body).not.toContain("<outline");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
import { Env } from "../types";
|
||||||
|
import { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
|
import { feedRssUrl } from "../infrastructure/urls";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a string for use in an XML attribute value.
|
||||||
|
* Replaces &, <, >, and " with their XML entity equivalents.
|
||||||
|
*/
|
||||||
|
function escapeXmlAttr(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for GET /admin/opml
|
||||||
|
* Exports all feeds as an OPML 2.0 document.
|
||||||
|
* Protected by the admin auth middleware (inherits from admin Hono app).
|
||||||
|
*/
|
||||||
|
export async function handleOpml(c: Context<{ Bindings: Env }>) {
|
||||||
|
const env = c.env;
|
||||||
|
const feeds = await FeedRepository.from(env).listFeeds();
|
||||||
|
|
||||||
|
const outlines = feeds
|
||||||
|
.map((feed) => {
|
||||||
|
const title = escapeXmlAttr(feed.title);
|
||||||
|
const xmlUrl = escapeXmlAttr(feedRssUrl(feed.id, env));
|
||||||
|
const descAttr = feed.description
|
||||||
|
? ` description="${escapeXmlAttr(feed.description)}"`
|
||||||
|
: "";
|
||||||
|
return ` <outline type="rss" text="${title}" title="${title}" xmlUrl="${xmlUrl}"${descAttr}/>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const opml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<opml version="2.0">
|
||||||
|
<head>
|
||||||
|
<title>kill-the-news feeds</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${outlines}
|
||||||
|
</body>
|
||||||
|
</opml>`;
|
||||||
|
|
||||||
|
return new Response(opml, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/x-opml; charset=utf-8",
|
||||||
|
"Content-Disposition": 'attachment; filename="feeds.opml"',
|
||||||
|
"X-Robots-Tag": "noindex",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { handle } from "./rss";
|
||||||
|
import { createMockEnv } from "../test/setup";
|
||||||
|
import { Env } from "../types";
|
||||||
|
|
||||||
|
describe("RSS Feed Route", () => {
|
||||||
|
let testApp: Hono;
|
||||||
|
let mockEnv: Env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockEnv = createMockEnv() as unknown as Env;
|
||||||
|
testApp = new Hono();
|
||||||
|
testApp.get("/:feedId", handle);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unknown feed", () => {
|
||||||
|
it("returns 404 when no metadata exists in KV", async () => {
|
||||||
|
const res = await testApp.request("/nonexistent-feed", {}, mockEnv);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(await res.text()).toBe("Feed not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("valid feed with no emails", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
"feed:empty-feed:metadata",
|
||||||
|
JSON.stringify({ emails: [] }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with application/rss+xml content type", async () => {
|
||||||
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toContain("application/rss+xml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes Cache-Control header", async () => {
|
||||||
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
||||||
|
expect(res.headers.get("Cache-Control")).toBe("max-age=1800");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets X-Robots-Tag: noindex", async () => {
|
||||||
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
||||||
|
expect(res.headers.get("X-Robots-Tag")).toBe("noindex");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Link header advertises hub and self for WebSub discovery", async () => {
|
||||||
|
const res = await testApp.request("/empty-feed", {}, mockEnv);
|
||||||
|
const link = res.headers.get("Link") ?? "";
|
||||||
|
expect(link).toContain(`rel="hub"`);
|
||||||
|
expect(link).toContain(
|
||||||
|
`<https://${mockEnv.DOMAIN}/rss/empty-feed>; rel="self"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("read/write id decoupling", () => {
|
||||||
|
const OPAQUE_ID = "kZ8xQ2pLm4nR7vT1wB9yJc";
|
||||||
|
const MAILBOX = "river.castle.42";
|
||||||
|
const RECEIVED_AT = 1700000002000;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const emailKey = `feed:${OPAQUE_ID}:${RECEIVED_AT}`;
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
emailKey,
|
||||||
|
JSON.stringify({
|
||||||
|
subject: "Private",
|
||||||
|
from: "Sender <sender@example.com>",
|
||||||
|
content: "<p>secret body</p>",
|
||||||
|
receivedAt: RECEIVED_AT,
|
||||||
|
headers: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${OPAQUE_ID}:metadata`,
|
||||||
|
JSON.stringify({
|
||||||
|
emails: [
|
||||||
|
{ key: emailKey, subject: "Private", receivedAt: RECEIVED_AT },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${OPAQUE_ID}:config`,
|
||||||
|
JSON.stringify({
|
||||||
|
title: "Decoupled Feed",
|
||||||
|
language: "en",
|
||||||
|
mailbox_id: MAILBOX,
|
||||||
|
created_at: 1700000000000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// The inbound index points the address at the feed (reception only).
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(`inbound:${MAILBOX}`, OPAQUE_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves the feed by its opaque read id", async () => {
|
||||||
|
const res = await testApp.request(`/${OPAQUE_ID}`, {}, mockEnv);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when read by the inbound mailbox (no coupling)", async () => {
|
||||||
|
const res = await testApp.request(`/${MAILBOX}`, {}, mockEnv);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never leaks the inbound mailbox in the feed body", async () => {
|
||||||
|
const res = await testApp.request(`/${OPAQUE_ID}`, {}, mockEnv);
|
||||||
|
expect(await res.text()).not.toContain(MAILBOX);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("conditional GET (ETag + Last-Modified)", () => {
|
||||||
|
const FEED_ID = "test-feed-rss-cget";
|
||||||
|
const EMAIL_RECEIVED_AT = 1700000001000;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const emailKey = `feed:${FEED_ID}:${EMAIL_RECEIVED_AT}`;
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
emailKey,
|
||||||
|
JSON.stringify({
|
||||||
|
subject: "RSS Subject",
|
||||||
|
from: "Sender <sender@example.com>",
|
||||||
|
content: "<p>Body</p>",
|
||||||
|
receivedAt: EMAIL_RECEIVED_AT,
|
||||||
|
headers: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${FEED_ID}:metadata`,
|
||||||
|
JSON.stringify({
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
key: emailKey,
|
||||||
|
subject: "RSS Subject",
|
||||||
|
receivedAt: EMAIL_RECEIVED_AT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${FEED_ID}:config`,
|
||||||
|
JSON.stringify({
|
||||||
|
title: "RSS Cget Feed",
|
||||||
|
language: "en",
|
||||||
|
created_at: 1700000000000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first GET returns 200 with ETag and Last-Modified headers", async () => {
|
||||||
|
const res = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("ETag")).toBeTruthy();
|
||||||
|
expect(res.headers.get("Last-Modified")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET with matching If-None-Match returns 304 with empty body", async () => {
|
||||||
|
const first = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
|
||||||
|
const etag = first.headers.get("ETag")!;
|
||||||
|
|
||||||
|
const res = await testApp.request(
|
||||||
|
`/${FEED_ID}`,
|
||||||
|
{ headers: { "If-None-Match": etag } },
|
||||||
|
mockEnv,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(304);
|
||||||
|
expect(await res.text()).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET with If-Modified-Since in the future returns 304", async () => {
|
||||||
|
const future = new Date(EMAIL_RECEIVED_AT + 1000).toUTCString();
|
||||||
|
const res = await testApp.request(
|
||||||
|
`/${FEED_ID}`,
|
||||||
|
{ headers: { "If-Modified-Since": future } },
|
||||||
|
mockEnv,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(304);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stale If-None-Match after new email results in 200", async () => {
|
||||||
|
// Get ETag before new email
|
||||||
|
const first = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
|
||||||
|
const oldEtag = first.headers.get("ETag")!;
|
||||||
|
|
||||||
|
// Add a newer email
|
||||||
|
const newReceivedAt = EMAIL_RECEIVED_AT + 5000;
|
||||||
|
const newEmailKey = `feed:${FEED_ID}:${newReceivedAt}`;
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
newEmailKey,
|
||||||
|
JSON.stringify({
|
||||||
|
subject: "Newer Email",
|
||||||
|
from: "Sender <sender@example.com>",
|
||||||
|
content: "<p>New body</p>",
|
||||||
|
receivedAt: newReceivedAt,
|
||||||
|
headers: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mockEnv.EMAIL_STORAGE.put(
|
||||||
|
`feed:${FEED_ID}:metadata`,
|
||||||
|
JSON.stringify({
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
key: newEmailKey,
|
||||||
|
subject: "Newer Email",
|
||||||
|
receivedAt: newReceivedAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `feed:${FEED_ID}:${EMAIL_RECEIVED_AT}`,
|
||||||
|
subject: "RSS Subject",
|
||||||
|
receivedAt: EMAIL_RECEIVED_AT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await testApp.request(
|
||||||
|
`/${FEED_ID}`,
|
||||||
|
{ headers: { "If-None-Match": oldEtag } },
|
||||||
|
mockEnv,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const newEtag = res.headers.get("ETag");
|
||||||
|
expect(newEtag).not.toBe(oldEtag);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RSS and Atom ETags for the same feed differ", async () => {
|
||||||
|
const rssRes = await testApp.request(`/${FEED_ID}`, {}, mockEnv);
|
||||||
|
const rssEtag = rssRes.headers.get("ETag")!;
|
||||||
|
|
||||||
|
// Use a separate atom app to get the atom ETag
|
||||||
|
const { handle: atomHandle } = await import("./atom");
|
||||||
|
const atomApp = new Hono();
|
||||||
|
atomApp.get("/:feedId", atomHandle);
|
||||||
|
const atomRes = await atomApp.request(`/${FEED_ID}`, {}, mockEnv);
|
||||||
|
const atomEtag = atomRes.headers.get("ETag")!;
|
||||||
|
|
||||||
|
expect(rssEtag).not.toBe(atomEtag);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+20
-1
@@ -5,6 +5,11 @@ import { fetchFeedData } from "../application/feed-fetcher";
|
|||||||
import { baseUrl, feedRssUrl } from "../infrastructure/urls";
|
import { baseUrl, feedRssUrl } from "../infrastructure/urls";
|
||||||
import { isExpired } from "../domain/feed";
|
import { isExpired } from "../domain/feed";
|
||||||
import { FeedId } from "../domain/value-objects/feed-id";
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
|
import {
|
||||||
|
computeFeedValidators,
|
||||||
|
isNotModified,
|
||||||
|
notModifiedResponse,
|
||||||
|
} from "../infrastructure/http-cache";
|
||||||
|
|
||||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
@@ -21,8 +26,19 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
return new Response("Feed has expired", { status: 410 });
|
return new Response("Feed has expired", { status: 410 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validators = computeFeedValidators(
|
||||||
|
"rss",
|
||||||
|
feedId,
|
||||||
|
feedData.feedConfig,
|
||||||
|
feedData.emails,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isNotModified(c.req.raw, validators)) {
|
||||||
|
return notModifiedResponse(validators);
|
||||||
|
}
|
||||||
|
|
||||||
const base = baseUrl(c.env);
|
const base = baseUrl(c.env);
|
||||||
const selfUrl = new URL(c.req.url).origin + `/rss/${feedId}`;
|
const selfUrl = feedRssUrl(feedId, c.env);
|
||||||
const rssXml = generateRssFeed(
|
const rssXml = generateRssFeed(
|
||||||
feedData.feedConfig,
|
feedData.feedConfig,
|
||||||
feedData.emails,
|
feedData.emails,
|
||||||
@@ -40,7 +56,10 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/rss+xml",
|
"Content-Type": "application/rss+xml",
|
||||||
"Cache-Control": "max-age=1800",
|
"Cache-Control": "max-age=1800",
|
||||||
|
"X-Robots-Tag": "noindex",
|
||||||
Link: linkHeader,
|
Link: linkHeader,
|
||||||
|
ETag: validators.etag,
|
||||||
|
"Last-Modified": validators.lastModified,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -139,14 +139,14 @@ function setupFeedTableResizing(): void {
|
|||||||
title: 220,
|
title: 220,
|
||||||
feedId: 120,
|
feedId: 120,
|
||||||
email: 160,
|
email: 160,
|
||||||
rss: 160,
|
formats: 200,
|
||||||
actions: 160,
|
actions: 160,
|
||||||
};
|
};
|
||||||
const defaultWidths: Record<string, number> = {
|
const defaultWidths: Record<string, number> = {
|
||||||
title: 340,
|
title: 340,
|
||||||
feedId: 160,
|
feedId: 160,
|
||||||
email: 220,
|
email: 220,
|
||||||
rss: 220,
|
formats: 230,
|
||||||
actions: 200,
|
actions: 200,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -615,3 +615,41 @@ async function bulkDeleteSelectedEmails(): Promise<void> {
|
|||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
initEmailUI();
|
initEmailUI();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Confirmation banner dismiss ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const dismissBtn = document.getElementById("confirmation-dismiss");
|
||||||
|
const banner = document.getElementById("confirmation-banner");
|
||||||
|
if (dismissBtn && banner) {
|
||||||
|
dismissBtn.addEventListener("click", () => {
|
||||||
|
const feedId = banner.getAttribute("data-feed-id") ?? "";
|
||||||
|
fetch(`/admin/feeds/${feedId}/confirmation/dismiss`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
if ((d as { ok?: boolean }).ok) banner.remove();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Native-feed banner dismiss ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const nativeDismissBtn = document.getElementById("native-feed-dismiss");
|
||||||
|
const nativeBanner = document.getElementById("native-feed-banner");
|
||||||
|
if (nativeDismissBtn && nativeBanner) {
|
||||||
|
nativeDismissBtn.addEventListener("click", () => {
|
||||||
|
const feedId = nativeBanner.getAttribute("data-feed-id") ?? "";
|
||||||
|
fetch(`/admin/feeds/${feedId}/native-feed/dismiss`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
if ((d as { ok?: boolean }).ok) nativeBanner.remove();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
+237
-11
@@ -295,6 +295,19 @@ label {
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="email"],
|
input[type="email"],
|
||||||
input[type="search"],
|
input[type="search"],
|
||||||
@@ -554,6 +567,20 @@ textarea:focus {
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-view-link {
|
||||||
|
margin-left: auto;
|
||||||
|
align-self: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-view-link:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Email content container */
|
/* Email content container */
|
||||||
.email-content {
|
.email-content {
|
||||||
margin-top: var(--spacing-md);
|
margin-top: var(--spacing-md);
|
||||||
@@ -887,6 +914,103 @@ table.table code {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Feed format chips ("Subscribe" block) ── */
|
||||||
|
.feed-formats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-formats-label {
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-formats-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: 4px 6px 4px 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: rgba(60, 60, 67, 0.06);
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-chip:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-chip-label {
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-chip-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-action,
|
||||||
|
.copyable.copyable-chip .copyable-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-action:hover,
|
||||||
|
.copyable.copyable-chip .copyable-content:hover {
|
||||||
|
background-color: rgba(60, 60, 67, 0.18);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strip the boxed wrapper styling so only the icon button shows in a chip */
|
||||||
|
.copyable.copyable-chip {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyable.copyable-chip .copyable-content {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact variant for the feeds table cell */
|
||||||
|
.feed-formats-compact .feed-formats-chips {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-formats-compact .format-chip {
|
||||||
|
padding: 2px 4px 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.button-delete {
|
.button-delete {
|
||||||
transition:
|
transition:
|
||||||
background-color 180ms ease,
|
background-color 180ms ease,
|
||||||
@@ -1064,17 +1188,6 @@ table.table code {
|
|||||||
border-color: rgba(255, 69, 58, 0.35);
|
border-color: rgba(255, 69, 58, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Validation badges */
|
|
||||||
.feed-validate {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-validate img {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Feed and Email Lists */
|
/* Feed and Email Lists */
|
||||||
.feed-list,
|
.feed-list,
|
||||||
.email-list {
|
.email-list {
|
||||||
@@ -1246,3 +1359,116 @@ table.table code {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Subscription confirmation surfacing ── */
|
||||||
|
|
||||||
|
/* Inline badge in the email-list subject cell (next to .attachment-indicator) */
|
||||||
|
.confirmation-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: 1px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard pill — <a class="pill pill-confirmation"> */
|
||||||
|
.pill-confirmation {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
opacity var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-confirmation:hover {
|
||||||
|
opacity: 0.88;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard pill — <a class="pill pill-native"> */
|
||||||
|
.pill-native {
|
||||||
|
background: var(--color-card);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
opacity var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-native:hover {
|
||||||
|
opacity: 0.88;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Native-feeds group sits below the KTN "Subscribe" chips */
|
||||||
|
.native-feeds {
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top banner on the feed emails page */
|
||||||
|
.confirmation-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: rgba(246, 130, 31, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.confirmation-banner {
|
||||||
|
background-color: rgba(224, 112, 16, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-banner-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bordered box on the email detail page */
|
||||||
|
.confirmation-section {
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border: 1px solid var(--color-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: rgba(246, 130, 31, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.confirmation-section {
|
||||||
|
background-color: rgba(224, 112, 16, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmation-section h2 {
|
||||||
|
margin: 0 0 var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary CTA button inside the confirmation section */
|
||||||
|
.confirmation-primary {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column of confirmation links */
|
||||||
|
.confirmation-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|||||||
@@ -247,3 +247,8 @@
|
|||||||
.site-footer-sponsor:hover {
|
.site-footer-sponsor:hover {
|
||||||
color: #db61a2 !important;
|
color: #db61a2 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-footer-version {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeAll, afterAll, afterEach } from "vitest";
|
import { beforeAll, afterAll, afterEach } from "vitest";
|
||||||
import { setupServer } from "msw/node";
|
import { setupServer } from "msw/node";
|
||||||
|
import { feedKeys } from "../domain/feed-keys";
|
||||||
|
|
||||||
// Minimal Node.js built-ins used only in this test setup file.
|
// Minimal Node.js built-ins used only in this test setup file.
|
||||||
// Declared locally to avoid pulling in the full @types/node package,
|
// Declared locally to avoid pulling in the full @types/node package,
|
||||||
@@ -258,7 +259,21 @@ export const createMockEnv = (options: { withR2?: boolean } = {}) => ({
|
|||||||
EMAIL_STORAGE: new MockKV(),
|
EMAIL_STORAGE: new MockKV(),
|
||||||
DOMAIN: "test.getmynews.app",
|
DOMAIN: "test.getmynews.app",
|
||||||
ADMIN_PASSWORD: "test-password",
|
ADMIN_PASSWORD: "test-password",
|
||||||
|
FALLBACK_FORWARD_ADDRESS: undefined as string | undefined,
|
||||||
...(options.withR2
|
...(options.withR2
|
||||||
? { ATTACHMENT_BUCKET: new MockR2() as unknown as R2Bucket }
|
? { ATTACHMENT_BUCKET: new MockR2() as unknown as R2Bucket }
|
||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the `inbound:<mailbox> → <feedId>` index that email reception resolves
|
||||||
|
* through. Defaults the feed id to the mailbox (the common unit-test shape where
|
||||||
|
* a feed is keyed by the same string as its inbound address).
|
||||||
|
*/
|
||||||
|
export async function seedInboundIndex(
|
||||||
|
env: { EMAIL_STORAGE: { put: (k: string, v: string) => Promise<unknown> } },
|
||||||
|
mailboxId: string,
|
||||||
|
feedId: string = mailboxId,
|
||||||
|
): Promise<void> {
|
||||||
|
await env.EMAIL_STORAGE.put(feedKeys.inbound(mailboxId), feedId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export interface Env {
|
|||||||
PROXY_TRUSTED_IPS?: string;
|
PROXY_TRUSTED_IPS?: string;
|
||||||
PROXY_AUTH_SECRET?: string;
|
PROXY_AUTH_SECRET?: string;
|
||||||
FEED_TTL_HOURS?: string;
|
FEED_TTL_HOURS?: string;
|
||||||
|
// Optional catch-all fallback: non-feed inbound mail is forwarded here instead
|
||||||
|
// of being dropped. Must be a *verified* Cloudflare Email Routing destination.
|
||||||
|
FALLBACK_FORWARD_ADDRESS?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stored attachment metadata (bytes live in R2, keyed by id)
|
// Stored attachment metadata (bytes live in R2, keyed by id)
|
||||||
@@ -39,10 +42,15 @@ export interface EmailData {
|
|||||||
export interface FeedConfig {
|
export interface FeedConfig {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
// Inbound mailbox local part (noun.noun.NN): the feed's email address is
|
||||||
|
// `mailbox_id@domain`. Decoupled from the feed's id (the opaque read id).
|
||||||
|
mailbox_id: string;
|
||||||
allowed_senders?: string[];
|
allowed_senders?: string[];
|
||||||
blocked_senders?: string[];
|
blocked_senders?: string[];
|
||||||
language: string;
|
language: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
// When true, entry titles in the feed output are rendered as `[Sender] Subject`.
|
||||||
|
sender_in_title?: boolean;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at?: number;
|
updated_at?: number;
|
||||||
expires_at?: number; // Unix timestamp ms — present when a TTL is configured
|
expires_at?: number; // Unix timestamp ms — present when a TTL is configured
|
||||||
@@ -55,6 +63,23 @@ export interface FeedMetadata {
|
|||||||
// RFC 8058 one-click unsubscribe URLs, keyed by sender so each newsletter on
|
// RFC 8058 one-click unsubscribe URLs, keyed by sender so each newsletter on
|
||||||
// the feed keeps its own (latest) link; fired when the feed is deleted.
|
// the feed keeps its own (latest) link; fired when the feed is deleted.
|
||||||
unsubscribe?: Record<string, string>;
|
unsubscribe?: Record<string, string>;
|
||||||
|
// True while at least one unactioned confirmation email is present. Raised on
|
||||||
|
// ingest, lowered by an admin "dismiss" or when the last confirmation email is
|
||||||
|
// removed. Projected into feeds:list for the dashboard.
|
||||||
|
pendingConfirmation?: boolean;
|
||||||
|
// Native syndication feeds (Atom/RSS/JSON) senders advertised via
|
||||||
|
// <link rel="alternate">, keyed by sender. Latest non-empty per sender wins.
|
||||||
|
nativeFeeds?: Record<string, NativeFeed[]>;
|
||||||
|
// True when the admin dismissed the native-feed notice; suppresses the
|
||||||
|
// dashboard pill while the URLs stay available in the feed detail view.
|
||||||
|
nativeFeedDismissed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A syndication feed a newsletter advertises about itself (via
|
||||||
|
// <link rel="alternate">), as opposed to the KTN-generated feed.
|
||||||
|
export interface NativeFeed {
|
||||||
|
url: string;
|
||||||
|
type: "rss" | "atom" | "json";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email metadata interface (summary info for listing)
|
// Email metadata interface (summary info for listing)
|
||||||
@@ -65,6 +90,11 @@ export interface EmailMetadata {
|
|||||||
size?: number;
|
size?: number;
|
||||||
attachmentIds?: string[]; // Downloadable attachments (shown to the user)
|
attachmentIds?: string[]; // Downloadable attachments (shown to the user)
|
||||||
inlineAttachmentIds?: string[]; // Inline images: hidden from lists, still cleaned up
|
inlineAttachmentIds?: string[]; // Inline images: hidden from lists, still cleaned up
|
||||||
|
messageId?: string; // RFC 2822 Message-ID header (dedup primary key)
|
||||||
|
dedupHash?: string; // SHA-256 hex of normalized subject+content (dedup fallback)
|
||||||
|
// Detected subscription-confirmation links (ranked top-3). Present ⇒ the email
|
||||||
|
// was detected as a confirmation request.
|
||||||
|
confirmation?: { links: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feed list interface
|
// Feed list interface
|
||||||
@@ -77,7 +107,10 @@ export interface FeedListItem {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
mailbox_id: string; // Cached inbound address local part (admin/API display)
|
||||||
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
|
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
|
||||||
|
pendingConfirmation?: boolean; // Projected from FeedMetadata for the dashboard
|
||||||
|
hasNativeFeed?: boolean; // Projected from FeedMetadata for the dashboard pill
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cumulative monitoring counters (persisted as a KV singleton)
|
// Cumulative monitoring counters (persisted as a KV singleton)
|
||||||
@@ -86,6 +119,10 @@ export interface Counters {
|
|||||||
feeds_deleted: number;
|
feeds_deleted: number;
|
||||||
emails_received: number;
|
emails_received: number;
|
||||||
emails_rejected: number;
|
emails_rejected: number;
|
||||||
|
// Subset of emails_rejected: non-feed mail forwarded to FALLBACK_FORWARD_ADDRESS
|
||||||
|
// instead of dropped. Dropped count = emails_rejected − emails_forwarded.
|
||||||
|
emails_forwarded: number;
|
||||||
|
emails_deduplicated: number; // Duplicate deliveries silently skipped (not stored)
|
||||||
unsubscribes_sent: number;
|
unsubscribes_sent: number;
|
||||||
last_email_at?: string; // ISO 8601
|
last_email_at?: string; // ISO 8601
|
||||||
last_feed_created_at?: string; // ISO 8601
|
last_feed_created_at?: string; // ISO 8601
|
||||||
@@ -102,6 +139,7 @@ export interface StatsResponse extends Counters {
|
|||||||
active_feeds: number;
|
active_feeds: number;
|
||||||
websub_subscriptions_active: number;
|
websub_subscriptions_active: number;
|
||||||
attachments_enabled: boolean;
|
attachments_enabled: boolean;
|
||||||
|
version: string; // Running app version (package.json), inlined at build time
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSub (PubSubHubbub) subscription configuration
|
// WebSub (PubSubHubbub) subscription configuration
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"lib": ["ES2021"],
|
"lib": ["ES2021"],
|
||||||
"types": ["@cloudflare/workers-types"],
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Web domain (used for feed URLs and admin U
|
|||||||
# the admin UI is pre-filled and read-only. Remove to allow per-feed configuration.
|
# the admin UI is pre-filled and read-only. Remove to allow per-feed configuration.
|
||||||
# FEED_TTL_HOURS = "24"
|
# FEED_TTL_HOURS = "24"
|
||||||
|
|
||||||
|
# Optional: catch-all fallback forwarding. Inbound mail that isn't a feed (bad
|
||||||
|
# address or unknown feed) is forwarded here instead of dropped — lets you point
|
||||||
|
# a domain's catch-all at this worker without losing personal mail. The address
|
||||||
|
# MUST be a *verified* destination in Cloudflare Email Routing or forwarding fails.
|
||||||
|
# FALLBACK_FORWARD_ADDRESS = "you@example.com"
|
||||||
|
|
||||||
# Optional: external proxy auth (Authelia/Authentik)
|
# Optional: external proxy auth (Authelia/Authentik)
|
||||||
# Comma-separated IPs of trusted reverse proxies
|
# Comma-separated IPs of trusted reverse proxies
|
||||||
# PROXY_TRUSTED_IPS = "10.0.0.1"
|
# PROXY_TRUSTED_IPS = "10.0.0.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user