256 Commits

Author SHA1 Message Date
Julien Herr 44fcbfc4f6 fix(favicon): fall back to apex domain when subdomain hosts no icon
Senders on a subdomain that hosts no favicon (e.g. mail.example.com) left
feeds blank because both the direct /favicon.ico and the DuckDuckGo lookup
were tried only against the full subdomain. Resolution now walks up to the
apex via Domain.parents() and caches the result under the original sender
domain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:49:43 +02:00
Julien Herr 4d3a94d1ec fix(confirmation): flag code-based OTP signups with no clickable link
Detect verification-code signups (e.g. "your verification code is
371404") whose only link is a mailto. These cleared the keyword
threshold but were dropped because the detector required an http(s)
candidate link. A code path now raises the flag/badge/banner when a
verification keyword sits next to an OTP-style code; the code is never
extracted or surfaced.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:46:14 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 23:35:10 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 23:30:20 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 23:18:38 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 23:04:33 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 22:49:57 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 22:44:35 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 22:36:16 +02:00
Julien Herr 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 <noreply@anthropic.com>
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 v0.3.1 2026-05-25 19:01:38 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 19:00:48 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 19:00:38 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 18:42:51 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 18:38:38 +02:00
Julien Herr cbf6bb7e7e chore: open 0.4.0 develop cycle
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:16:00 +02:00
Julien Herr 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 <noreply@anthropic.com>
v0.3.0
2026-05-25 18:00:02 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 17:59:55 +02:00
Julien Herr ea7332b752 docs: document native feed detection; mark TODO item shipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 Herr 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 <noreply@anthropic.com>
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 Herr 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 <noreply@anthropic.com>
2026-05-25 17:33:10 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 17:28:44 +02:00
Julien Herr 6236274ce8 refactor(app): derive native-feed base from EmailAddress.siteBaseUrl
Delete the `iconBase` local helper (which mishandled display-name form
like `Name <a@b.com>`) 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 <noreply@anthropic.com>
2026-05-25 17:25:52 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 17:22:05 +02:00
Julien Herr ee0e7eef5d feat(infra): project hasNativeFeed into feeds:list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:18:19 +02:00
Julien Herr 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 <noreply@anthropic.com>
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 Herr 3c48181c05 feat(infra): extract rel=alternate feed links from email HTML
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 Herr 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 <noreply@anthropic.com>
2026-05-25 17:01:36 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 16:54:51 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 16:19:22 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 16:08:24 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 16:04:32 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 15:56:36 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 15:52:51 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 15:50:45 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 15:48:31 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 15:43:05 +02:00
Julien Herr 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 <noreply@anthropic.com>
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