Commit Graph
100 Commits
Author SHA1 Message Date
Julien HerrandClaude Opus 4.7 3f35435610 fix(confirmation): recognize localized subscribe CTAs in weak link signals
The weak link-signal vocabulary was English-only, so a genuine double
opt-in whose confirm button reads "Je m'inscris…" over an opaque tracking
redirect scored 0 on every link and was missed. Make the weak vocab
multilingual (FR/DE/ES) to match the confirmation keywords.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 23:35:10 +02:00
Julien HerrandClaude Opus 4.7 a353de1342 fix(favicon): raise max icon size to 256 KB for hi-res PNGs
DuckDuckGo serves hi-res PNG favicons that legitimately exceed the old
100 KB cap, causing them to be rejected and negatively cached.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 23:30:20 +02:00
Julien HerrandClaude Opus 4.7 fd3ff8c40a feat(admin): show email count and last-email date per feed
Surface each feed's email count on its Emails button and a "Last email …"
freshness line under the title, in both dashboard views. The values are
projected into feeds:list (kept to a single KV read) via the Feed aggregate,
so toListItemDTO now maps the whole aggregate through its intention-revealing
accessors instead of threading scalar projections. Also fixes long titles
overflowing into the Feed ID column in the table view.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 23:18:38 +02:00
Julien HerrandClaude Opus 4.7 e258206384 fix(feed): advertise WebSub hub in RSS/Atom body
Readers like FreshRSS discover the hub from <atom:link rel="hub"> in the
feed XML, not the HTTP Link header. Without it they never subscribe and
only refresh on cache expiry (~30 min) instead of receiving an instant
push when a new email arrives.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 23:04:33 +02:00
Julien HerrandClaude Opus 4.7 7297e06b94 fix(feed): escape bare ampersands in entry HTML attribute URLs
linkedom escapes & in text nodes but not in attribute values, so URLs
with query strings (?a=1&b=2) serialized with bare ampersands. Valid XML
inside the feed CDATA, but the W3C validator parses the embedded HTML and
warns "Named entity expected. Got none." on <description>/<content:encoded>
(RSS) and <summary>/<content> (Atom). Escape every & not already starting
a valid entity; covers all three formats via processEmailContent.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 22:49:57 +02:00
Julien HerrandClaude Opus 4.7 5f13126b35 fix(favicon): short TTL for negative favicon cache entries
A failed favicon lookup was cached for a full week (same TTL as a
success), so a transient miss (e.g. the icon not yet indexed upstream)
blacklisted the domain for days. Cache negatives for 6 hours instead so
the next email retries.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 22:44:35 +02:00
Julien HerrandClaude Opus 4.7 bb9fce72ff fix(confirmation): detect confirm emails whose CTA hint is in the link text
Weak subscribe/subscription signals are now matched on the link href OR its
visible text (matched once, not additively), so a double opt-in email whose
button reads "Yes, subscribe me…" over an opaque tracking-redirect href is no
longer missed. Adds a regression test with anonymized fixture data.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 22:36:16 +02:00
Julien HerrandClaude Opus 4.7 b6b160a186 fix(release): set GitHub Release title to the tag
--notes-file (unlike --generate-notes) leaves the release name blank; pass
--title so releases keep a heading.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 19:02:54 +02:00
Julien Herr a9814ca063 chore: open 0.4.0 develop cycle 2026-05-25 19:01:44 +02:00
Julien Herr d778849e02 chore(release): 0.3.1 2026-05-25 19:01:38 +02:00
Julien HerrandClaude Opus 4.7 5083f7e151 chore: retarget develop cycle to 0.3.1
Only a bug fix (feed self link) landed since 0.3.0, so the next release is a
patch, not a minor. Correct the prematurely-opened 0.4.0 cycle.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 19:00:48 +02:00
Julien HerrandClaude Opus 4.7 ffe96586c7 chore(release): add CHANGELOG and scripted release pipeline
Introduce CHANGELOG.md (Keep a Changelog) as the single source of release
notes, and scripts/release.sh (npm run release X.Y.Z) which promotes the
Unreleased section, commits the bare version as a real release commit, tags
it, and reopens the next -develop cycle. The Release workflow now verifies the
tagged commit's version equals the tag and publishes the CHANGELOG section as
the release notes instead of auto-generated commit lists.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 19:00:38 +02:00
Julien HerrandClaude Opus 4.7 3242f0e3f1 docs(landing): add native FreshRSS support feature card
Surface the xExtension-KillTheNews integration as a differentiator on
the marketing landing page.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 18:42:51 +02:00
Julien HerrandClaude Opus 4.7 1332362005 fix(feeds): self link uses configured domain, not request host
The RSS/Atom/JSON self link was derived from the request origin, leaking
the workers.dev host when reached directly instead of via the custom
domain. Use the configured-domain URL builders so self matches alternate.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 18:38:38 +02:00
Julien HerrandClaude Opus 4.7 cbf6bb7e7e chore: open 0.4.0 develop cycle
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 18:16:00 +02:00
Julien HerrandClaude Opus 4.7 0f18d4c123 refactor(app): parse sender once via EmailAddress, drop infra reach
email-processor parsed input.from twice — once via EmailAddress for the
native-feed base, once via the favicon infra helper extractEmailDomain
just to get the domain. CLAUDE.md forbids reaching across a layer to
parse a domain: parse once and derive both siteBaseUrl() and domain.value
from the EmailAddress VO, removing the infrastructure/favicon-fetcher import.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 18:00:02 +02:00
Julien HerrandClaude Opus 4.7 e8078b2673 refactor(admin): extract shared FeedChip, dedupe native/format chips
NativeFeedChip duplicated ~all of FormatChip's accessible copy-script
markup. Extract one FeedChip (copy + open + optional validate); both the
Subscribe formats and native feeds now render through it, keeping the
copyable-value/data-copy markup identical in one place.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 17:59:55 +02:00
Julien HerrandClaude Sonnet 4.6 ea7332b752 docs: document native feed detection; mark TODO item shipped
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 17:47:08 +02:00
Julien Herr 8cd8c940fa style(admin): pill-native + native-feeds group spacing 2026-05-25 17:44:31 +02:00
Julien HerrandClaude Sonnet 4.6 fe5728de59 feat(admin): native-feed detail group + dismissable notice
Wire the NativeFeeds chip group into the per-feed emails page, add a
dismissable banner that nudges users to subscribe directly, the dismiss
POST route mirroring the confirmation-dismiss idiom, and the client-side
handler in emails-page.ts.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 17:39:47 +02:00
Julien Herr 35262d5d0b refactor(admin): dedupe feed label map + add chip key 2026-05-25 17:36:48 +02:00
Julien HerrandClaude Sonnet 4.6 a18d9f165f feat(admin): native feed chips + dashboard pill
Add NativeFeeds/NativeFeedChip components to admin/ui.tsx and a
NativeFeedPill rendered in both list and table dashboard views when
feed.hasNativeFeed is set.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 17:33:10 +02:00
Julien HerrandClaude Sonnet 4.6 8a0dbf25b0 feat(api): expose nativeFeeds on the REST Feed schema (read-only)
GET /v1/feeds/{id} and PATCH /v1/feeds/{id} now include a required
nativeFeeds array (possibly empty) derived from the feed metadata via
unionNativeFeeds. POST /v1/feeds always returns [].

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 17:28:44 +02:00
Julien HerrandClaude Sonnet 4.6 6236274ce8 refactor(app): derive native-feed base from EmailAddress.siteBaseUrl
Delete the `iconBase` local helper (which mishandled display-name form
like `Name <[email protected]>`) and replace it with `EmailAddress.parse(input.from)
?.siteBaseUrl()` — the domain-layer VO that already handles bare and
display-name addresses correctly.  Adds TEST C to lock the
display-name + relative-href absolutization fix.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 17:25:52 +02:00
Julien HerrandClaude Sonnet 4.6 5362d478e3 feat(app): detect native feeds during email ingestion
Wire extractFeedLinks + detectNativeFeeds into storeEmail so that RSS/Atom/JSON
feed <link> tags in the newsletter HTML are detected and stored per-sender on the
feed metadata.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 17:22:05 +02:00
Julien HerrandClaude Sonnet 4.6 ee0e7eef5d feat(infra): project hasNativeFeed into feeds:list
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 17:18:19 +02:00
Julien HerrandClaude Sonnet 4.6 dc2ccfdd1c feat(domain): store native feeds per-sender on the Feed aggregate
Add nativeFeeds/nativeFeedDismissed to FeedMetadata and hasNativeFeed to
FeedListItem; extend IngestOptions with nativeFeeds; add nativeFeeds(),
hasNativeFeed(), and dismissNativeFeed() to the Feed aggregate mirroring
the existing pendingConfirmation/dismissConfirmation pattern.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 17:14:38 +02:00
Julien Herr 86d18eb390 docs(infra): clarify extractFeedLinks href-resolution comment 2026-05-25 17:12:08 +02:00
Julien HerrandClaude Sonnet 4.6 3c48181c05 feat(infra): extract rel=alternate feed links from email HTML
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 17:08:48 +02:00
Julien Herr df5546fedd feat(domain): add native-feed detector (Atom/RSS/JSON) 2026-05-25 17:05:39 +02:00
Julien HerrandClaude Opus 4.7 021aeabd05 docs(plan): implementation plan for native feed detection
11 TDD tasks: domain detector, extractFeedLinks, aggregate per-sender
storage, feeds:list projection, ingestion wiring, REST API field, admin
chips/pill/detail group + dismiss, styles, docs.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 17:01:36 +02:00
Julien HerrandClaude Opus 4.7 69ed07db51 docs(spec): design native Atom/RSS/JSON feed detection
Per-sender storage (latest-wins, like unsubscribe), pure domain detector
(strict 3 MIME types), admin surfaces (detail/badge/pill/dismiss) and a
read-only REST API field, mirroring the confirmation-detection feature.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 16:54:51 +02:00
Julien HerrandClaude Opus 4.7 6dad6741ed docs(claude): add Releasing guidance to avoid version mistakes
Document the -develop/tag-driven release contract in CLAUDE.md so a
release is cut correctly: main stays *-develop, the tag is the source of
truth, never commit a bare version, and re-bump the next cycle after.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 16:19:22 +02:00
Julien HerrandClaude Opus 4.7 d2f3e1ca27 ci(release): derive release version from the tag, not the commit
The release job built whatever version package.json held at the tagged
commit — but main always carries a -develop suffix, so a vX.Y.Z bundle
would have reported X.Y.Z-develop. Make the tag the source of truth:
strip the suffix in the ephemeral CI checkout before building (never
committed), and fail fast when the tag base doesn't match package.json's
base (wrong-commit guard). Update CONTRIBUTING with the tag-driven flow.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 16:08:24 +02:00
Julien HerrandClaude Opus 4.7 664d0c02ba chore(release): carry 0.3.0-develop pre-release version between releases
package.json now holds a -develop pre-release suffix so the version
reported in the footer/health/stats distinguishes a dev build from a
shipped one (0.3.0-develop sorts below 0.3.0 per SemVer). Document the
release flow in CONTRIBUTING.md: strip the suffix at tag time, re-bump
to the next -develop afterward.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 16:04:32 +02:00
Julien HerrandClaude Opus 4.7 757dd3a53f refactor(domain): own sender display name on EmailAddress
Push the "Name <addr>" display-name parsing onto the EmailAddress VO
(displayName field + label() = displayName ?? normalized) and delete the
ad-hoc parseFromAddress helper in feed-generator. The feed builder now
parses the from-address once via EmailAddress and reuses it for the site
base URL, the [Sender] title prefix, and the entry author — one parser,
on the type that owns the data.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 15:56:36 +02:00
Julien HerrandClaude Opus 4.7 16029460bc refactor(feed): drop unused aggregate getter, dedupe sender parse
The sender-in-title rendering reads the FeedConfig DTO via the read
model, so the Feed.senderInTitle getter had no consumer — remove it.
In buildFeed, parse the from-address once and reuse it for both the
title prefix and the author; drop the dead `?? email.from` fallback
(parseFromAddress already returns the address as the name).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 15:52:51 +02:00
Julien HerrandClaude Opus 4.7 82a4bd8341 feat(api): expose app version in /api/v1/stats
Add version to StatsResponse and getStats so the canonical public
monitoring endpoint reports the running build alongside the footer
and /health.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 15:50:45 +02:00
Julien HerrandClaude Opus 4.7 e86beeeb8a feat(feed): optional per-feed sender-in-title toggle
Add a per-feed senderInTitle flag (domain FeedState.senderInTitle ↔
FeedConfig.sender_in_title). When set, the feed generator prefixes each
entry title with [Sender] (display name, falling back to the address).
Exposed as an admin edit-form checkbox and across the REST API
create/update/response schemas.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 15:48:31 +02:00
Julien HerrandClaude Opus 4.7 7086526670 feat(admin): display running version in footer and /health
Inline package.json version at bundle time via src/config/version.ts
(resolveJsonModule), surface it in the shared admin/status footer and
add it to the /health JSON so self-hosters can tell which build runs.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 15:43:05 +02:00
Julien HerrandClaude Opus 4.7 70552e5fa6 refactor(admin): reuse dashboard Subscribe chips on feed detail page
Hoist the shared format chips, expiry pill, and copy icons into admin/ui.tsx
so the feed detail (emails) page renders the same Email + Subscribe block as
the dashboard list, dropping the old per-format rows, W3C validator images,
and the now-dead .feed-validate CSS.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 15:20:08 +02:00
Julien Herr 4e3d378850 fix(domain): cut confirmation false positives via weak subscribe signal
A "manage subscription" / "subscribe" footer link is now a weak (+1) URL
signal instead of strong (+2), so an ordinary newsletter with a stray body
keyword (active/valid) no longer crosses the detection threshold. A genuine
"confirm your subscription" subject + a bare /subscribe link still passes.
Also dedupe surfaced links. Adds false-positive + recall + dedupe tests.
2026-05-25 10:58:20 +02:00
Julien Herr 421430632e test: cover plaintext confirmation, table-view pill, emails-page banner 2026-05-25 10:57:46 +02:00
Julien Herr 5f05068449 refactor(domain): slim detectConfirmation contract
Return the ranked links directly (string[] | null) instead of an unused
{score, links} wrapper, and drop the redundant hasKeyword helper in favor
of matchesAny(_, KEYWORDS). No behavior change.
2026-05-25 10:50:06 +02:00
Julien Herr fc86b5f7d1 refactor(infra): simplify toListItemDTO pendingConfirmation projection 2026-05-25 09:22:01 +02:00
Julien Herr 0929d4f0b7 docs: subscription confirmation surfacing + TODO bookkeeping 2026-05-25 09:18:34 +02:00
Julien Herr 4d47ca623b style(admin): confirmation badge, pill, banner, section 2026-05-25 09:16:24 +02:00
Julien Herr 7019800769 feat(admin): land on feed emails page after creation 2026-05-25 09:14:48 +02:00
Julien Herr f44c6c1eda feat(admin): dashboard pending-confirmation pill 2026-05-25 09:12:27 +02:00
Julien HerrandClaude Sonnet 4.6 1525b36cab feat(admin): surface confirmation link, badge, banner + dismiss
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 09:08:30 +02:00
Julien Herr c4d591b962 feat(ingest): detect and mark confirmation emails 2026-05-25 09:04:36 +02:00
Julien HerrandClaude Sonnet 4.6 36d58ade48 feat(infra): project pendingConfirmation into feeds:list
saveMetadata now also upserts the list entry so the pendingConfirmation
flag is reflected in the dashboard without an extra per-feed KV read.
toListItemDTO gains an optional third parameter for the flag.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 09:02:16 +02:00
Julien Herr 79bb4902b9 feat(domain): pendingConfirmation flag on the Feed aggregate 2026-05-25 08:58:26 +02:00
Julien Herr d561b6b81f feat(infra): extractLinks for confirmation detection 2026-05-25 08:55:24 +02:00
Julien HerrandClaude Sonnet 4.6 e4e3d62f5a refactor(domain): clearer diacritics escape + hasKeyword boolean
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-25 08:38:46 +02:00
Julien Herr f2e0353438 feat(domain): confirmation-email detection service 2026-05-25 08:36:54 +02:00
Julien HerrandClaude Opus 4.7 6bf11493ab docs(plan): subscription confirmation surfacing implementation plan
11 TDD tasks: domain detection service, extractLinks, types + aggregate
flag, feeds:list projection, ingestion wire-in, admin UI (detail section,
list badge, banner, dismiss), dashboard pill, post-creation redirect,
styles, docs/TODO bookkeeping, green close.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 08:33:57 +02:00
Julien HerrandClaude Opus 4.7 5b54659b4d docs(spec): subscription confirmation surfacing design
Brainstormed design for detecting newsletter confirmation emails at
ingestion, marking them, and surfacing the confirmation link in the admin
(detail section, list badge, dashboard pill, emails-page banner). v1 does
no outbound request; server on-detect actions deferred.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 08:29:11 +02:00
Julien HerrandClaude Opus 4.7 0488b9d066 docs(todo): add multi-user, change-admin-password & admin-managed API tokens
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-25 08:00:53 +02:00
Julien HerrandClaude Opus 4.7 2a3aeb8a18 feat(admin): link email detail to its public entry page
Add a "Public page" link next to the Rendered/Raw toggle in the admin
email view, opening the standalone /entries/:feedId/:entryId render.
Centralize the entry route shape in a pure entryPath() builder, used by
both the admin link and the RSS/Atom/JSON feed generator.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 23:26:16 +02:00
Julien HerrandClaude Opus 4.7 b3a979fd03 feat(admin): per-feed Subscribe chips with copy/open/validate for RSS/Atom/JSON
Replace the stacked RSS/Atom URL rows in the dashboard with a compact
"Subscribe" chip block exposing all three feed formats — including JSON
Feed, previously absent from the admin UI. Each chip carries copy, open,
and validate actions; validation links to the W3C Feed Validator (RSS/Atom)
and validator.jsonfeed.org (JSON). The Table view's RSS+Atom columns fold
into a single Formats column.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 23:08:27 +02:00
Julien HerrandClaude Opus 4.7 1a4a479190 feat: decouple read FeedId from inbound MailboxId
Separate the two feed identities so the public read URL never reveals the
inbound address and vice-versa:

- FeedId becomes an opaque high-entropy token (read id + KV key); MailboxId
  (noun.noun.NN) owns the inbound address and the untrusted-input boundary
  via MailboxId.parse. They map only through the inbound:<mailbox> secondary
  index, resolved solely at reception.
- inbound index lifecycle is owned by FeedRepository: written by save/saveConfig,
  dropped by removeFromList(Bulk) — symmetric, never mirrored by hand (removes the
  manual delete in feed-service + the cron loop, and a silent empty-catch).
- Feed.mailboxId exposes a MailboxId VO (symmetry with Feed.id); the
  mailbox@domain shape lives on MailboxId.emailAddress(domain).
- Distinguish mailbox_unknown (no feed claims the address) from feed_not_found
  (dangling index) for observability; both forwardable, both 404.
- Drop the redundant EmailParser.extractMailbox pass-through so MailboxId.parse
  is the single parse boundary.

Docs (README/INSTALL/CLAUDE.md/landing) and tests updated; 439 tests green,
tsc clean, build dry-run OK.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 22:46:37 +02:00
Julien HerrandClaude Opus 4.7 f7f10779bc docs(todo): add JSON Feed v1.1 upgrade + version/update/instances items
- JSON Feed v1.1: post-process .json1() output (lib issue #139 closed
  not-planned, stays on version/1) — set version 1.1, authors[], language
- Display running version (admin/status/health), bundled from package.json
- Notify when a newer GitHub Release exists (cached check, opt-out-able)
- Opt-in instance telemetry: central counter and/or public directory,
  privacy-first (off by default, no PII, "count me but don't list me")

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 20:58:44 +02:00
Julien HerrandClaude Opus 4.7 0abd5f306c feat: reader-compat batch — JSON Feed, OPML export, conditional GET, dedup
Batch of four reader-facing improvements (TODO "Compat lecteurs + dedup"):

- JSON Feed at /json/:feedId (feed lib .json1()); all formats cross-link
- OPML export at /admin/opml (admin-protected; the registry lists every
  feed URL, so it must not be public)
- Conditional GET on /rss + /atom: strong ETag + Last-Modified, 304 on
  If-None-Match/If-Modified-Since, validators shared via http-cache.ts
- Duplicate-send dedup in ingestion: match by Message-ID, fall back to a
  SHA-256 of normalized subject+content; a duplicate is a no-op and bumps
  the new emails_deduplicated counter (status page + /api/v1/stats)

429 tests green, tsc clean, build dry-run OK. Docs (README/CLAUDE/TODO +
landing cards) updated.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 20:47:54 +02:00
Julien HerrandClaude Opus 4.7 334713fbd9 docs(todo): add pluggable runtime/storage/ingestion epic (off-Cloudflare)
Catalog the Clever Cloud + Sweego portability epic: adapter matrix,
seven independently-deliverable sub-tasks, open questions, scope, and
acceptance criteria for keeping CF-native and self-host profiles
behavior-equivalent from one codebase.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 17:52:01 +02:00
Julien HerrandClaude Opus 4.7 97ce9a62b4 feat: reader-rendering correctness + privacy hardening (P1·S batch)
Close the five open P1·S items from TODO.md:
- X-Robots-Tag: noindex on rss/atom/entries/files + a /robots.txt
- absolutize relative content URLs against the sender's site
- promote lazy-loaded images (data-src → src, strip loading="lazy")
- strip XML-illegal control chars from generated feeds (keep emoji)
- plain-text feed <title> (strip HTML, decode entities)

Sender-base derivation lives on the EmailAddress value object
(siteBaseUrl) instead of a misplaced favicon helper. Bump to 0.2.1
and document the changes in README + CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 17:47:46 +02:00
Julien HerrandClaude Opus 4.7 81e46c9026 feat(stats): count emails forwarded to the catch-all fallback
Adds an emails_forwarded counter (a subset of emails_rejected) bumped on a
successful FALLBACK_FORWARD_ADDRESS forward. Dropped = rejected − forwarded.
Surfaced in the /api/v1/stats response and the public status page.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 17:19:12 +02:00
Julien HerrandClaude Opus 4.7 1583e95875 docs(landing): add Catch-All Fallback feature card
Surfaces FALLBACK_FORWARD_ADDRESS on the landing page feature grid.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 17:15:42 +02:00
Julien HerrandClaude Opus 4.7 2c450817df feat(email): forward non-feed mail to FALLBACK_FORWARD_ADDRESS
Lets you point a domain's catch-all at the worker without losing personal
mail: inbound mail that isn't a feed (invalid_address / feed_not_found) is
forwarded to an optional verified destination instead of being dropped.
Expired feeds and blocked senders are still dropped so newsletters never
leak to the fallback inbox. Unset env keeps the original drop-and-log path.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 17:14:04 +02:00
Julien HerrandClaude Opus 4.7 6cb036fe2c docs(todo): catalog feature gaps with origin refs and priority/size tags
Survey of kill-the-newsletter issues/PRs, competitors, RSS readers, and a
code audit. Each idea carries an origin reference (so we can notify the
requester on ship) and a Pn·Size badge (user value × implementation effort).
Adds the FALLBACK_FORWARD_ADDRESS catch-all fallback-forwarding idea.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 17:11:11 +02:00
Julien HerrandClaude Opus 4.7 b002f8ad43 refactor: extract inline view CSS into dedicated stylesheets
Move the hardcoded <style> blocks from the single-email view and the admin
email preview iframe into src/styles/*.css so they benefit from Prettier,
linting, and syntax highlighting like the rest of the design system.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 14:54:33 +02:00
Julien HerrandClaude Opus 4.7 5137637181 feat(attachments): render inline cid images in place, not as attachments
Inline images (referenced by src="cid:…") are now classified at ingest and
kept out of the downloadable attachment lists, RSS/Atom enclosures, and the
API — while still stored in R2 and cleaned up with the email. Fixes the admin
email preview, which injected raw HTML into the data: iframe so cid refs never
resolved; it now rewrites them to absolute /files URLs.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 14:39:59 +02:00
Julien HerrandClaude Opus 4.7 be45e70571 docs(landing): add auto-expiring feeds feature card
Surface the feed TTL / auto-deletion capability on the landing page,
framed as a disposable temporary-mailbox use case.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 14:13:45 +02:00
Julien HerrandClaude Opus 4.7 06c436c36a refactor: separate Feed domain state from persistence DTO
Move four DDD tensions on the Feed aggregate to ground:

- #1 The aggregate now holds a domain FeedState (camelCase) instead of the
  snake_case FeedConfig DTO; infrastructure/feed-mapper.ts owns the
  FeedState<->FeedConfig/FeedListItem translation as the sole snake_case site
  outside the HTTP edge.
- #3 Replace the edit() recomputeExpiry control flag with a Lifetime VO:
  passing a lifetime recomputes expiry, omitting it preserves the current one
  (the dashboard quick-edit path).
- #4 Domain events carry their own feedId; dispatchFeedEvents centralizes the
  drain+dispatch in the application layer (no more manual pullEvents at call
  sites), keeping infra->application dependency direction intact.
- #6 Rename FeedId.fromTrusted to FeedId.unchecked to make the absence of
  revalidation explicit.

Adds Lifetime + feed-mapper round-trip tests. 353 tests green, tsc clean,
wrangler dry-run OK. Docs (CLAUDE.md) synced.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 14:10:04 +02:00
Julien HerrandClaude Opus 4.7 d68a24867d docs(claude): sync layering rules with the aggregate refactor
The aggregate no longer exposes raw config/metadata (intention-revealing
accessors + snapshots), feeds:list is maintained by the repository from
feed.summary(), edit() is the single config mutation, and FeedId now flows
through the application and infrastructure layers.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 13:45:30 +02:00
Julien HerrandClaude Opus 4.7 ad196f1761 refactor: tighten DDD boundaries on the Feed aggregate
Address five modeling tensions in one pass:

- Encapsulation: the Feed aggregate no longer exposes raw config/metadata
  (a shallow Readonly still leaked mutable arrays). It now offers
  intention-revealing accessors that return copies, plus
  toConfigSnapshot/toMetadataSnapshot for the repository and summary() for
  the global registry.
- feeds:list consistency: FeedRepository.save/saveConfig upsert the registry
  entry from feed.summary(), so services no longer mirror title/description/
  expiry by hand (the old add/updateInList footgun is gone).
- domain/feed.ts: drop the dead applySenderPolicy, internalise resolveExpiresAt
  and trimToByteBudget into the aggregate; feed.ts keeps only the shared
  isExpired predicate used by the read-model routes.
- Single edit path: remove editDetails; edit(patch, deps) is the sole config
  mutation, with a systematic expired guard. Renaming an expired feed now 403s.
- FeedId flows through the application and infrastructure signatures;
  fromTrusted/parse happen once at the edge, .value only at the serialisation
  boundaries (urls, feed-generator, feed-keys, logs, JSON).

347 tests green, tsc clean, Worker bundle builds.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 13:45:13 +02:00
Julien HerrandClaude Opus 4.7 b3d42f6c50 refactor: introduce domain events for feed side effects (Track E — point 5)
Light "collect + dispatch" variant: the Feed aggregate records FeedEvents
(FeedCreated, EmailIngested) on the mutations that have consequences, exposed via
pullEvents(). A new application dispatcher (feed-events.applyFeedEvents) maps
those events to their side effects — counters (awaited) plus WebSub pings and
favicon fetches handed to a BackgroundScheduler. This removes the inline,
scattered side effects from the ingest hot path (email-processor) and from
createFeedRecord; the aggregate is now the source of truth for "what happened".

Side effects with no aggregate mutation (rejected email, feed deletion bypassing
the aggregate, bulk admin ops, the cron, unsubscribes-sent) stay imperative by
design — there is no aggregate event for them to ride on.

BackgroundScheduler type moved to infrastructure/worker.ts (shared). CLAUDE.md
updated. 355 tests pass (+4 event tests); tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 13:12:42 +02:00
Julien HerrandClaude Opus 4.7 46af982c40 refactor: invert application↔routes boundary (Track B — points 3, 6a)
- Point 3: move the feed/email storage-cleanup helpers (purgeFeedKeysStep,
  collectUnsubscribeUrls, purgeExpiredFeeds, deleteKeysWithConcurrency,
  deleteAttachmentsForEmails) out of routes/admin/helpers.ts into
  src/application/feed-cleanup.ts, so the application layer no longer imports
  from routes/. deleteFeedRecord no longer takes a Hono Context: it accepts a
  BackgroundScheduler ((task) => void) and the HTTP edge passes
  (p) => waitUntilSafe(c, p). Application/domain are now Hono-Context-free.
- Point 6a: rename the misleadingly-named Feed.rename → Feed.editDetails (it
  edits title + description), and feed-service.renameFeed → editFeedDetails.

CLAUDE.md source layout updated. 351 tests pass; tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 10:05:21 +02:00
Julien HerrandClaude Opus 4.7 f823a5f222 refactor: move KV repositories to infrastructure (Track P — points 2, 6c)
Make the domain stop depending on infrastructure ("imports point inward").

- Point 2: relocate the four KV adapters (FeedRepository, IconRepository,
  WebSubSubscriptionRepository, CountersRepository) from domain/ to
  infrastructure/, where the logger import is legitimate. The domain now keeps
  only the pure key schema (feed-keys.ts), the Feed aggregate and value objects;
  it imports nothing outward. Deliberately no hand-rolled 24-method port
  interface (YAGNI without DI) — relocation alone fixes the direction.
- Point 6c: EmailParser.extractFeedId now returns a validated FeedId value
  object instead of a raw string, so the most untrusted input (an inbound
  recipient address) is guarded at the parse boundary and no longer round-trips
  through FeedId.fromTrusted in the ingest path.

All import paths updated; CLAUDE.md source layout/KV-schema notes updated.
351 tests pass; tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 10:02:23 +02:00
Julien HerrandClaude Opus 4.7 23dd0a0c96 refactor(domain): purify the Feed aggregate (Track D — points 1, 4, 6b)
Remove the infrastructure Env leak and ambient time from the domain core, and
model the sender policy as a value object.

- Point 1: Feed.create/edit no longer receive Env. The application layer resolves
  the effective lifetime (parsing FEED_TTL_HOURS and applying the server override)
  via feed-service.resolveTtlHours and hands the domain a plain ttlHours.
  resolveExpiresAt(ttlHours, now) is now pure.
- Point 4: introduce a Clock port (systemClock default), injected at
  create/reconstitute. The aggregate uses clock.now() instead of Date.now().
  The isExpired edge helper keeps its Date.now() default for routes.
- Point 6b: extract SenderPolicy value object built once from the lists
  (decide(senders)) instead of re-parsing per sender; applySenderPolicy is now a
  thin wrapper over it.

Coverage moved with the logic: the FEED_TTL_HOURS override is now pinned by
feed-service.test.ts; aggregate tests use an injected fixed clock.

351 tests pass; tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 09:55:55 +02:00
Julien HerrandClaude Opus 4.7 e324571122 docs(claude): document the DDD layering, Feed aggregate and repo split
Refresh the source layout for domain/application/infrastructure, replace
the single-repository rule with the four-repository split + feed-keys,
and add domain rules: the Feed aggregate is the only writer of config +
the email index, and FeedId circulates through domain and repository.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:48:13 +02:00
Julien HerrandClaude Opus 4.7 7bf0f71f86 refactor: split src into domain / application / infrastructure layers
Replace the history-driven lib/ + utils/ split with DDD layers:
- domain/: aggregate, repositories, value objects, pure parsers/format
- application/: feed-service, email-processor, feed-fetcher, stats
- infrastructure/: logging, auth, KV/R2 adapters, HTTP, framework glue

Pure file relocation; imports updated mechanically. Behaviour unchanged.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:46:56 +02:00
Julien HerrandClaude Opus 4.7 ab1c15e69a refactor(domain): make FeedId circulate through the domain and repository
FeedId is now the type of Feed.id and of every single-feed method on
FeedRepository; callers wrap raw strings via FeedId.fromTrusted at the
repository boundary. String-medium operations (URLs, logs, JSON,
list registry, email keys) stay string. Drop the redundant
generateFeedId wrapper in favour of FeedId.generate.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:44:24 +02:00
Julien HerrandClaude Opus 4.7 05388b45c8 refactor(domain): split updateFeedRecord into renameFeed and editFeed
The inPlace boolean hid two distinct intentions. Replace it with two
intention-revealing operations backed by Feed.rename (presentational,
never touches expiry) and Feed.edit (full edit, recomputes expiry,
rejects expired). Add FeedRepository.saveConfig so these config-only
edits don't re-write (and risk clobbering) the email index.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:35:07 +02:00
Julien HerrandClaude Opus 4.7 c45f6677fe refactor(domain): introduce the Feed aggregate as the write-path API
Add a Feed aggregate class owning config + the email index, with create,
ingest, removeEmails, isExpired and accepts delegating to the existing
pure invariant functions. FeedRepository gains load/save/saveMetadata
that reconstitute and persist the aggregate.

All write paths now go through it: createFeedRecord (Feed.create),
email ingestion (feed.ingest), and every email deletion in the admin UI
and REST API (feed.removeEmails) — no route mutates metadata.emails
directly anymore. KV key strings unchanged.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:33:14 +02:00
Julien HerrandClaude Opus 4.7 a31ff42f59 refactor(domain): split Icon/WebSub/Counters out of FeedRepository
FeedRepository no longer owns favicons, WebSub subscriber lists or the
monitoring counters singleton. Each concern gets its own repository
(IconRepository, WebSubSubscriptionRepository, CountersRepository),
sharing the key schema via feed-keys. KV key strings are unchanged;
counters increment policy stays in utils/stats.ts.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:27:33 +02:00
Julien HerrandClaude Opus 4.7 b347f2f625 refactor(domain): extract the KV key schema into feed-keys.ts
Move the feed:/icon:/websub: key builders out of FeedRepository into a
pure feed-keys module so the wire format lives in one place, shared by
the repositories to come. Strings are byte-identical; behaviour unchanged.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:24:43 +02:00
Julien HerrandClaude Opus 4.7 49f69ff19e docs(claude): document the domain layer and FeedRepository KV access rule
Reflect the refactor: add the src/domain/ tree (feed-repository, feed, value
objects), drop the deleted storage.ts, and update the KV-schema note to point at
FeedRepository as the single key-access layer. Correct the websub key shape and
add the icon: key.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:06:48 +02:00
Julien HerrandClaude Opus 4.7 c65aabe7f4 refactor(domain): add FeedId, EmailAddress and Domain value objects
Encapsulate the email/domain/feed-id parsing that was scattered as ad-hoc
regexes and split("@") calls into three small immutable value objects under
src/domain/value-objects/. EmailParser.extractFeedId and generateFeedId now
delegate to FeedId; the sender policy, favicon domain extraction and the admin
SenderField parse through EmailAddress/Domain.

Left as-is on purpose: forwardemail's multi-address free-text extraction and the
admin allow/block list normaliser, which operate on mixed email-or-domain input
that the single-address value objects would reject.

Behaviour-preserving; adds unit tests for each value object.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:05:46 +02:00
Julien HerrandClaude Opus 4.7 8f036cf223 refactor(ingest): return a domain result from processEmail, map HTTP at the edge
processEmail/validateEmail now return an IngestResult discriminated union
({ ok } | { ok: false; reason }) instead of an HTTP Response. The status mapping
moves to the edge (ingestResultToResponse in forwardemail.ts), and the Cloudflare
email handler now logs the rejection reason instead of silently discarding it.

The ingestion core is transport-agnostic. End-to-end status mapping stays covered
by inbound.test.ts (now incl. 410 expired); email-processor.test asserts on the
domain result directly.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 00:02:05 +02:00
Julien HerrandClaude Opus 4.7 6b51173722 refactor(domain): consolidate Feed aggregate invariants in domain/feed.ts
Gather the feed's scattered business rules — expiry, sender allow/block policy,
and the email byte-size budget — into one framework-agnostic module. Expiry was
duplicated across feed-service, email-processor and the rss/atom/entries routes;
the sender policy and trim loop lived inline in email-processor. Each now calls
a single function (isExpired, applySenderPolicy, trimToByteBudget,
resolveExpiresAt). Drops the now-unused MAX_METADATA_EMAILS constant.

Behaviour-preserving; adds feed.test.ts covering every invariant.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 23:59:15 +02:00
Julien HerrandClaude Opus 4.7 2b3f00f7e3 refactor(domain): introduce FeedRepository as the single KV access layer
Centralise the KV key schema and all get/put access behind a FeedRepository
class under src/domain/. Every feed/email/list/icon/websub/counter key was
previously inlined across ~12 modules with two divergent storeEmail and
addFeedToList implementations; the dead src/utils/storage.ts write path is
removed and the email key convention unified on feed:<id>:<ts>.

Behaviour-preserving: existing tests pass unchanged in logic, plus a new
feed-repository.test.ts covering CRUD, key builders, list ops and counters.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 23:56:44 +02:00
Julien HerrandClaude Opus 4.7 a0eaebe749 docs(landing): add a REST API & OpenAPI feature card
Surface the new versioned REST API on the marketing landing, linking to the
live Scalar reference on the demo.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 23:16:54 +02:00
Julien HerrandClaude Opus 4.7 c2a0a68058 refactor(api): remove the deprecated /api/stats endpoint
The only consumer (the marketing landing) now uses /api/v1/stats, so drop
the legacy /api/stats route and its handler. Delete src/routes/stats.ts and
its test; repoint the index CORS test at /api/v1/stats.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 23:15:08 +02:00
Julien HerrandClaude Opus 4.7 daa93d8093 feat(api): make /api/v1/stats public and point the landing at it
Unify the monitoring stats on the versioned API: /api/v1/stats is now public
(no auth) and CORS-enabled, mirroring the legacy /api/stats. The marketing
landing (docs/index.html) now fetches /api/v1/stats; /api/stats is kept as a
deprecated alias for existing monitors. Feed/email routes remain token-gated.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 23:12:43 +02:00
Julien HerrandClaude Opus 4.7 45d2a14a12 feat(api): add versioned REST API with OpenAPI 3.1 spec
Expose /api/v1/* for feed and email management (feeds CRUD, email
list/get/delete, stats) so the service can be automated without scraping
the admin UI. Built on @hono/zod-openapi; the OpenAPI 3.1 spec is served at
/api/openapi.json with a Scalar reference at /api/docs.

Auth is token-based (Authorization: Bearer <ADMIN_PASSWORD>) plus the
existing reverse-proxy headers — no cookie, no CSRF. Extracted the auth
primitives into src/lib/auth.ts and the feed create/update/delete
orchestration into src/lib/feed-service.ts so the admin UI and the REST API
share a single source of truth.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 23:01:15 +02:00
Julien HerrandClaude Opus 4.7 7f5b913576 docs: add SECURITY.md and CONTRIBUTING.md
Add a security policy with private reporting channels and project-specific
scope, plus a contributor guide covering dev setup, testing, and commit
conventions. Drop the stale AGENTS.md reference from CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 22:18:57 +02:00
Julien HerrandClaude Opus 4.7 db31e33a8b docs: extract install/deploy/config guide into INSTALL.md
Slim README down to project overview (why, features, architecture,
security) with a short Installation quick-start that links to the new
INSTALL.md. Repoint setup.sh references and CLAUDE.md maintenance list.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 21:49:19 +02:00
Julien HerrandClaude Opus 4.7 8ffa4ad5e7 docs(license): add Julien Herr copyright alongside original author
Cumulative MIT copyright reflecting the substantial rewrite while
preserving the original author's notice as required by the license.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 21:49:13 +02:00
Julien HerrandClaude Opus 4.7 7f4afa3ec8 feat(admin): add status page link to dashboard header
Add a "Status" link in the admin header pointing to the public status page
(/), mirroring the existing "Go to admin" link on that page. Add a gap to
.header-actions so the new link and Logout button are spaced apart.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 21:43:24 +02:00