Commit Graph
100 Commits
Author SHA1 Message Date
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
Julien HerrandClaude Opus 4.7 3368b0d1d2 feat(admin): collapse create-feed form into accordion
Wrap the "Create New Feed" form in a native <details> accordion, collapsed
by default and auto-opened when no feeds exist. After creating a feed,
redirect to the "Your Feeds" anchor so the new feed is immediately visible.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 21:41:25 +02:00
Julien HerrandClaude Opus 4.7 5fc91a0be4 refactor(html-processor): isolate cid rewrite from sanitization
Keep sanitizeElement single-purpose and run the cid: rewrite as a
separate guarded pass over [src] elements. Use a type-only import for
AttachmentData.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 18:47:20 +02:00
Julien HerrandClaude Opus 4.7 debbfc623e fix(attachments): render inline cid: images in emails and feeds
Capture each attachment's Content-ID at ingestion (postal-mime and
mailparser paths) and rewrite cid: image refs to the stored /files URL
in processEmailContent, shared by the entry view and RSS/Atom feeds.
Bodyless HTML fragments are now serialized so sanitization and the cid
rewrite apply to them too.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 18:42:04 +02:00
Julien HerrandClaude Opus 4.7 6cd2d425a2 feat(attachments): list downloadable attachments on admin email detail page
The admin email detail view loaded the full email but never rendered its
attachments, so there was no way to download them from the admin UI (only
the public entry view and the feed enclosure exposed them).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 18:11:29 +02:00
Julien HerrandClaude Opus 4.7 9141cf89bd fix(attachments): purge R2 attachments on no-JS bulk email delete
The form-based bulk-delete fallback removed KV entries but left R2
attachments orphaned. Extract a shared deleteAttachmentsForEmails helper
and use it across single, JSON bulk, and form bulk delete paths.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 17:57:31 +02:00
Julien HerrandClaude Opus 4.7 ddde0e26a2 docs(readme): add Continuous deployment section with CI secrets and token permissions
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 17:51:50 +02:00
Julien HerrandClaude Opus 4.7 20c9bca34a docs(readme): note R2 permission needed for scoped deploy tokens
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 17:50:04 +02:00
Julien HerrandClaude Opus 4.7 2de09b2a5d refactor(home): dedupe byte formatting in storage cards
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 17:39:55 +02:00
Julien HerrandClaude Opus 4.7 f150d40c45 feat(attachments): R2 toggle, storage metrics, and demo R2 config
Add an ATTACHMENTS_ENABLED switch (default on when R2 is bound) via a
central getAttachmentBucket helper, surface R2 + estimated KV usage
against the free tier on the status page and /api/stats (refreshed by the
hourly cron), let setup.sh create and wire the R2 bucket, and bind the
demo bucket so the deployed demo has attachments.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 17:33:50 +02:00
Julien HerrandClaude Opus 4.7 7226e718f7 feat(admin): paperclip indicator for emails with attachments
Show an inline paperclip icon before the subject in the admin email
list when an email has attachments, with the count in a tooltip. Uses
the attachmentIds already stored in metadata, so no extra fetch.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 15:10:24 +02:00
Julien HerrandClaude Opus 4.7 f4e751e40b fix(favicon): add landing-page favicon to fix /favicon.ico 404
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 14:49:57 +02:00
Julien HerrandClaude Opus 4.7 766f2717a7 feat(entries): list email attachments with download links
The email detail page loaded the full EmailData (including attachments)
but never rendered them, so attachments were invisible. Add a conditional
"Attachments" section linking each file to /files/:id/:filename with name
and human-readable size.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 14:46:25 +02:00
Julien HerrandClaude Opus 4.7 d322bc1e92 docs(todo): add REST API with OpenAPI description item
Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 14:36:16 +02:00
Julien HerrandClaude Opus 4.7 3ad0188bc0 feat(unsubscribe): RFC 8058 one-click unsubscribe on feed deletion
Capture each sender's List-Unsubscribe one-click URL during ingestion
(stored per sender in feed metadata, mirroring the iconDomain pattern) and
fire one-click POSTs via ctx.waitUntil when a feed is deleted, so newsletters
stop mailing the now-dead address. Tracked with a new unsubscribes_sent
counter surfaced on the status page and /api/stats.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 14:35:05 +02:00
Julien HerrandClaude Opus 4.7 eb12f21894 feat(favicon): per-feed icon from the last sender's domain
Resolve each feed's most recent sender domain and serve its favicon at
GET /favicon/:feedId, falling back to the project icon. Icons are fetched
in the background on ingestion (direct /favicon.ico then a DuckDuckGo
fallback), cached base64 in KV keyed by domain with a 1-week TTL so the
fetch only fires when absent. Exposed via RSS <image> / Atom <icon>/<logo>
and rendered in the admin feed list, plus a landing-page feature card.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 14:05:14 +02:00
Julien HerrandClaude Opus 4.7 d299c8891d feat(favicon): serve project favicon reusing the header envelope logo
Serve an inline SVG icon at /favicon.svg and /favicon.ico and link it
from the shared Layout and the standalone entry view, so the admin UI,
status page, and entry pages stop emitting /favicon.ico 404s. Doubles
as the fallback for the upcoming per-feed favicon feature.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 13:13:44 +02:00
Julien HerrandClaude Opus 4.7 a67baa71f4 feat(landing): add FAQ section and trim demo URL
Add a native details/summary accordion FAQ inspired by kill-the-newsletter,
rewritten for self-hosted differentiators; drop /admin from the demo URL.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 12:59:27 +02:00
Julien HerrandClaude Opus 4.7 b985e2c643 feat(status): redesign status page with hero, themed sections, responsive grid
Rework the public / status page from a flat uniform grid into a hero
featured metric plus four themed sections (Feeds, Emails, Distribution,
Instance). Add semantic colors (green success, red rejects/deletes),
relative timestamps with UTC tooltips, and derived metrics (net feeds,
acceptance rate, avg emails/feed, humanized uptime). Grid is fluid above
640px (auto-fit) and locks to two columns on mobile.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 12:39:57 +02:00
Julien HerrandClaude Opus 4.7 81d05e5774 feat(landing): move live counter above demo banner, point CTAs to demo root
Surface the live stats counter directly under the hero, ahead of the
"Try it live" banner. Demo CTAs (hero + banner) now open the demo root
instead of /admin so visitors land on the public status page first.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 10:43:30 +02:00
Julien HerrandClaude Opus 4.7 4db9fc1b8a fix(lint): close type-check gaps in client scripts and tooling
Remove unused import flagged by CI lint, then harden the toolchain so
such issues are caught before push:

- lint-staged now also matches .tsx/.jsx (previously .tsx files skipped
  the pre-commit eslint pass, which is how the error reached CI)
- eslint ignores generated client bundles (gitignored, not worth linting)
- typecheck now also runs the client tsconfig; the hand-written browser
  source was excluded from the root config and never type-checked
- consolidate the window global augmentations (showToast,
  parseJsonResponseOrThrow) into a single client globals.d.ts; the inline
  declare-global blocks failed (non-module files) and masked real errors

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 10:38:01 +02:00
Julien HerrandClaude Opus 4.7 6bfaa4dec7 fix(landing): eliminate mobile horizontal scroll
Fix the install step grid track (48px minmax(0,1fr)) so wide code blocks
and the WAF table no longer blow out the page width on mobile. Transpose
the WAF rate-limit table to a vertical layout (endpoints as columns,
settings as rows) and reclaim horizontal space with tighter mobile
padding.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 10:23:11 +02:00
Julien HerrandClaude Opus 4.7 fd6a1a945f feat(landing): show animated live demo stats counters
Add a "Live from the demo instance" section to the landing page that
fetches feeds_created and emails_received from the demo /api/stats and
counts them up on scroll into view. Make /api/stats publicly readable
(CORS *) and refresh the stale allowlist origins to kill-the.news.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 10:09:13 +02:00
Julien HerrandClaude Opus 4.7 d1959acad1 docs(todo): add favicon and RFC 8058 unsubscribe ideas
Add backlog items for a project favicon (also used as the per-feed
fallback), per-feed favicons resolved from the last sender's domain with
aggressive caching, and RFC 8058 one-click unsubscribe on feed deletion.
Include a detailed design breakdown for the per-feed favicon feature.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 09:57:59 +02:00
Julien HerrandClaude Opus 4.7 b534ce5bf8 feat(monitoring): add stats counters API and public status page
Add GET /api/stats exposing cumulative counters (feeds created/deleted,
emails received/rejected, recent date-times) plus live values (active
feeds, active WebSub subscriptions). Counters persist in a stats:counters
KV singleton and are incremented at the email-processing chokepoint and
feed create/delete paths. Replace the / → /admin redirect with a public
status page rendering these figures with a link to the admin.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-23 09:50:51 +02:00
Julien HerrandClaude Sonnet 4.6 f4d5edda0e feat(feeds): add configurable per-feed lifetime (TTL)
Replace the demo nightly KV wipe with a per-feed expiry. Feeds can be
given a lifetime at creation (and edited later); FEED_TTL_HOURS locks the
value server-side and greys out the UI field. Expired feeds stay visible
in admin (greyed, actions disabled), return 410 on rss/atom/entries, and
reject inbound emails. The scheduled handler now purges only expired
feeds (KV + R2 attachments) on an hourly global cron.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-23 09:05:48 +02:00
Julien HerrandClaude Sonnet 4.6 24c7d2a53e fix(release): create release before uploading bundle
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:50:39 +02:00
Julien HerrandClaude Sonnet 4.6 7e72022ffc fix(release): remove redundant cp and use env var for bundle path
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:49:25 +02:00
Julien HerrandClaude Sonnet 4.6 78117421fd fix(build): add wrangler.build.toml for CI and fix autoconfig
Wrangler 4.94+ introduced --experimental-autoconfig (default: true) which
fails in non-interactive CI environments. Without a committed wrangler.toml,
the release action build was broken.

- Add wrangler.build.toml with minimal config (placeholder KV ID, no secrets)
- Update build script to use wrangler.build.toml + --no-experimental-autoconfig

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:47:48 +02:00
Julien HerrandClaude Sonnet 4.6 1b31665b4f docs: update WAF rate limiting instructions for free tier accuracy
- Fix menu path: Security → Security rules (not Security → WAF)
- Add free tier limitations note: 1 rule max, 10s period/block cap
- Show recommended vs free tier limits side by side in table
- Remove HTTP method filter from conditions (not available in rate limiting rules)
- Note Terraform supports method filtering and longer periods (paid plan)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:38:41 +02:00
Julien HerrandClaude Sonnet 4.6 75a557d542 feat(admin): add site header and footer to all admin pages
Adds a minimal header with a branded link to kill-the.news and an
"admin" badge, plus a discreet footer with site link and GitHub
Sponsors link.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 23:23:48 +02:00
Julien HerrandClaude Sonnet 4.5 4a4c276859 feat: add sender blocklist with priority matching and quick-add dropdown
- Add `blocked_senders` field to FeedConfig (alongside existing `allowed_senders`)
- Refactor sender matching to priority-based logic: exact block > exact allow > domain block > domain allow, enabling exceptions (e.g. allow [email protected] despite blocking gmail.com)
- Add `POST /admin/feeds/:feedId/sender-filter` endpoint for quick allow/block from email detail view; returns 409 on conflict with opposite list
- Add ⋮ dropdown on From field in email detail with 4 options (allow/block sender/domain), inline success/error feedback
- Add blocked_senders textarea to create/edit feed forms
- 209 tests passing

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-22 23:09:53 +02:00
Julien HerrandClaude Sonnet 4.6 7b2b98d693 refactor: extract url helpers, add EMAIL_DOMAIN support
- Add src/utils/urls.ts with baseUrl, feedRssUrl, feedAtomUrl, feedUrl,
  feedEmailAddress, feedTopicPattern
- Add optional EMAIL_DOMAIN env var so web domain and email domain can
  differ (e.g. demo.kill-the.news serves feeds, @kill-the.news receives mail)
- Replace all inline domain template literals with the new helpers
- Remove unused site_url/feed_url fields from FeedConfig
- Remove unused feedPath param from fetchFeedData
- Extract verifyCallback() to deduplicate verifyAndStoreSubscription /
  verifyAndDeleteSubscription

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 22:38:29 +02:00
Julien HerrandClaude Sonnet 4.6 c64ceb4ee7 feat(site): add GitHub Sponsors links (nav, sponsor section, footer)
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 21:56:56 +02:00
Julien HerrandClaude Sonnet 4.6 6bf5ae0356 feat: landing page install guide, demo banner, WAF docs, nightly demo reset
- docs/index.html: nav links (Features/How it works/Install), hero CTAs
  (Try demo primary, Self-host, GitHub), demo banner with credentials,
  full 7-step installation section with WAF rate limiting guide (dashboard
  + Terraform) integrated as step 7
- wrangler-example.toml: cron trigger on demo env for nightly KV reset at 03:00 UTC
- src/index.ts: scheduled handler that wipes all EMAIL_STORAGE KV keys
- TODO.md: mark WAF rate limiting as done

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 21:50:42 +02:00
Julien Herr af721c081a chore: mark WebSub as done in TODO.md 2026-05-22 21:14:00 +02:00
Julien HerrandClaude Sonnet 4.5 a29e9ab372 feat: WebSub Atom support, HTML processing via linkedom, W3C badges
WebSub / PubSubHubbub:
- Hub now accepts both /rss/:id and /atom/:id topic URLs
- WebSubSubscription stores format ("rss" | "atom")
- notifySubscribers sends RSS or Atom XML with correct Content-Type
- verifyAndStoreSubscription sends correct topic URL per format
- CI paths-ignore docs/** to skip deploy on docs-only changes

HTML processing (linkedom + escape-html):
- New html-processor.ts: body extraction, script/iframe/object removal,
  event handler + javascript: URL stripping, mso-* style cleanup,
  plain text → <pre> with HTML escaping via escape-html
- feed-generator.ts and entries.ts use processEmailContent

Admin UI:
- W3C validation badges (Atom + RSS) on feed detail page

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
2026-05-22 21:12:22 +02:00
Julien HerrandClaude Sonnet 4.6 1789870f27 fix(feed): use permalink URL as Atom entry id, strip mso-* inline styles
- Entry <id> was a non-URL string (timestamp + base64 snippet), which
  is invalid per the Atom spec; now uses the entry permalink URL which
  is both valid and stable across feed regeneration
- Strip mso-* properties from inline style attributes in extracted body
  content to eliminate the feed validator DangerousStyleAttr warning
  caused by Microsoft Office HTML in newsletter emails

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 21:12:22 +02:00
Julien HerrandClaude Sonnet 4.6 afed4464cf fix(feed): use permalink URL as Atom entry id, strip mso-* inline styles
- Entry <id> was a non-URL string (timestamp + base64 snippet), which
  is invalid per the Atom spec; now uses the entry permalink URL which
  is both valid and stable across feed regeneration
- Strip mso-* properties from inline style attributes in extracted body
  content to eliminate the feed validator DangerousStyleAttr warning
  caused by Microsoft Office HTML in newsletter emails

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:43:06 +02:00
Julien HerrandClaude Sonnet 4.6 4428f35dd4 fix(feed): add Atom link in emails page, fix HTML stripping, use request URL for self-link
- Add Atom Feed URL to the Feed Details card in the emails page
- Fix extractBodyContent to handle emails without a closing </body> tag
  (regex now falls back to capturing everything after the opening <body>)
- Use the actual request URL origin for atom:link rel="self" in RSS/Atom
  feeds, guaranteeing it always matches the document location regardless
  of how DOMAIN is configured

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:41:21 +02:00
Julien HerrandClaude Sonnet 4.6 bcc9640591 fix(feed): correct feed link, canonical id, and strip html wrapper from content
- link: computed as /admin/feeds/:id/emails instead of stale site_url from KV
- id: computed dynamically from baseUrl instead of stale feed_url from KV
- item description/content: strip <html><head><body> wrapper via extractBodyContent()
  so feed readers receive a body fragment, not a full HTML document

Fixes RSS validator warnings: SelfDoesntMatchLocation (stale KV domain) and
InvalidHTML (full HTML document inside <description>/<content:encoded>).
Adds 8 tests covering extractBodyContent and the new feed/atom link assertions.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:28:13 +02:00
Julien HerrandClaude Sonnet 4.6 780bf6c190 fix(admin): add Atom feed URL, unify container width, update branding
- Atom feed URL shown in both list and table views (new Atom column)
- Remove container-wide toggle — both views now use max-width 1200px
- Update dashboard title and login title to kill-the-news

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-22 18:21:49 +02:00