Commit Graph

232 Commits

Author SHA1 Message Date
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
Julien Herr f44c6c1eda feat(admin): dashboard pending-confirmation pill 2026-05-25 09:12:27 +02:00
Julien Herr 1525b36cab feat(admin): surface confirmation link, badge, banner + dismiss
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 Herr 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 <noreply@anthropic.com>
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 Herr e4e3d62f5a refactor(domain): clearer diacritics escape + hasKeyword boolean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 Herr 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 <noreply@anthropic.com>
2026-05-25 08:33:57 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-25 08:29:11 +02:00
Julien Herr 0488b9d066 docs(todo): add multi-user, change-admin-password & admin-managed API tokens
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 08:00:53 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-24 23:26:16 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-24 23:08:27 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-24 22:46:37 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-24 20:58:44 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-24 20:47:54 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-24 17:52:01 +02:00
Julien Herr 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 <noreply@anthropic.com>
v0.2.1
2026-05-24 17:47:46 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-24 17:19:12 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-24 17:15:42 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-24 17:14:04 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-24 17:11:11 +02:00
Julien Herr 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 <noreply@anthropic.com>
v0.2.0
2026-05-24 14:54:33 +02:00
Julien Herr 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 <noreply@anthropic.com>
2026-05-24 14:39:59 +02:00