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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>