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>
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>
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>
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>
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>
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>
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>
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>
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>
--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>
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>
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>
Surface the xExtension-KillTheNews integration as a differentiator on
the marketing landing page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.