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>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Imports Inter 400/500/600/700 from Google Fonts to match the landing
page typography. Updates browser tab title format.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract CSS from TypeScript template literals into standalone .css files
(variables.css, layout.css, components.css, utilities.css) and update
src/routes/admin/ui.tsx to import them directly via Wrangler text imports,
concatenating the strings at runtime for the inline <style> tag.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves the inline JS template literal from emails.tsx into a typed
TypeScript source file. The dynamic feedId value (previously interpolated
directly) is now passed via a window.__APP_CONFIG__ bootstrap script
injected immediately before the compiled static script in the HTML.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves the 650-line inline JS template literal from admin.tsx into a
proper TypeScript source file with full type annotations. esbuild
compiles it to a minified IIFE committed in src/scripts/generated/,
which is imported and inlined into the HTML response as before.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Convert login page and dashboard GET routes from hono/html tagged template
literals to typed JSX using the <Layout> component. Extracts reusable
CopyIcon, CheckIcon, and CopyFieldInline components. Dashboard inline
script (~650 lines) preserved exactly via dangerouslySetInnerHTML constant.
All auth logic, CSRF middleware, and API routes are unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Convert feed emails list and single email view GET routes from hono/html
tagged template literals to typed JSX. Extracts reusable CopyField and
SVG icon components. Inline page scripts are preserved verbatim via
dangerouslySetInnerHTML. Raw HTML display in single email view uses
dangerouslySetInnerHTML to avoid double-escaping pre-escaped content.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Convert the edit feed GET route from hono/html tagged template literals
to typed JSX using the <Layout> component. All CRUD routes and business
logic are preserved unchanged. textarea placeholder special characters
are now handled via JSX attribute escaping rather than entities.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace hono/html tagged template layout() function with a typed JSX
<Layout> component. CSS and interactive scripts are injected via
dangerouslySetInnerHTML to preserve exact output. clampText() is
preserved and re-exported for consumers.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces src/lib/logger.ts emitting JSON lines (level, message, data)
compatible with Cloudflare Logpush. Replaces all console.log/warn/error
calls in email-processor.ts, index.ts, and hub.ts with structured logger
calls. Extracts waitUntilSafe into src/utils/worker.ts to avoid duplicating
the executionCtx guard across routes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>