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>
8.1 KiB
Detect a newsletter's native Atom/RSS/JSON feed — design
Date: 2026-05-25 · Backlog item: TODO.md "Detect a newsletter's native Atom/RSS feed" (P2·S)
Goal
When an incoming newsletter email's HTML advertises its own syndication feed via
<link rel="alternate" type="…">, detect it and surface it to the admin:
"this newsletter already publishes a feed — you can subscribe to it directly."
A genuine differentiator: it is the top item on upstream kill-the-newsletter's own TODO and is not built there. Per the user, detect Atom, RSS, and JSON Feed.
Approach
Reuse the existing, proven pipeline — identical to the confirmation-detection feature and the per-sender unsubscribe storage:
infra parses the HTML → a pure domain detector decides which links count →
the result rides into the aggregate via IngestOptions (like unsub) → the
aggregate stores it per-sender → admin surfaces it (detail + list badge +
dashboard pill + dismiss) → exposed read-only on the REST API.
Alternative considered and rejected: an ad-hoc detector living in infrastructure.
The "which MIME types are a feed" rule is business knowledge, so it belongs in
domain/, mirroring domain/confirmation.ts.
Data model
// New value shape
type NativeFeed = { url: string; type: "rss" | "atom" | "json" };
// FeedMetadata (src/types/index.ts) — additive, like `unsubscribe` / `pendingConfirmation`
nativeFeeds?: Record<string, NativeFeed[]>; // key = senderKey (same scheme as `unsubscribe`)
nativeFeedDismissed?: boolean; // dismiss: hides pill + badge, keeps the URLs
// FeedListItem (src/types/index.ts) — projected flag for the dashboard, like pendingConfirmation
hasNativeFeed?: boolean;
Update semantics (chosen by the user: "accumulation, but latest-per-sender"):
- Storage is per sender, keyed by the same
senderKeyused forunsubscribe(input.senders[0] || iconDomain || input.from). - Latest non-empty wins per sender: the most recent email from a given sender
that declares feeds overwrites that sender's list; other senders are preserved.
Mirrors how
ingestupdatesunsubscribeonly when an unsubscribe URL is present. - The aggregate exposes
nativeFeeds(): NativeFeed[]= the union across senders, deduped by URL (returns a copy). - Projected flag
hasNativeFeed = nativeFeeds().length > 0 && !nativeFeedDismissed.
Smart re-notify (avoid nagging on every email):
- On ingest, if a previously-unseen URL appears (not in the current union),
clear
nativeFeedDismissed(re-raise the notice). - If ingestion only re-discovers already-known URLs, leave
nativeFeedDismisseduntouched, so a dismiss sticks until a genuinely new native feed shows up.
Detection
Infra — src/infrastructure/html-processor.ts: new
extractFeedLinks(content): { href: string; type: string }[].
- Parse
<link>elements whosereltoken-list containsalternateand that carry atypeattribute (link[rel~="alternate"][type]). - Return the raw
href+typetuples; absolutize a relativehrefbest-effort via the existingtoAbsolutehelper; http(s) only (drop others). - Plain-text bodies have no
<link>→ returns[].
Domain — src/domain/native-feed.ts (pure, no DOM/IO, mirrors confirmation.ts):
detectNativeFeeds(links): NativeFeed[].
- Owns the recognized MIME → kind table (strict, the three canonical types only):
application/atom+xml→"atom"application/rss+xml→"rss"application/feed+json→"json"
- Ignores any other type (no
application/json— too broad, would capture non-feeds). - Dedupes by URL; preserves first-seen kind for a URL.
Ingestion wiring
src/application/email-processor.ts: alongside the existing confirmation +
unsubscribe extraction, call extractFeedLinks(input.content) →
detectNativeFeeds(...). When non-empty, pass into feed.ingest via
IngestOptions:
nativeFeeds?: { senderKey: string; feeds: NativeFeed[] };
senderKey is the same value already computed for unsub.
src/domain/feed.aggregate.ts:
ingest: whenopts.nativeFeedsis present, set_metadata.nativeFeeds[senderKey] = feeds; if any feed URL is new vs the pre-update union, set_metadata.nativeFeedDismissed = false.- Getter
nativeFeeds(): NativeFeed[]— union deduped by URL (copy). - Getter
hasNativeFeed(): boolean—nativeFeeds().length > 0 && !dismissed. dismissNativeFeed(): void— setsnativeFeedDismissed = true(lower-only, mirrorsdismissConfirmation).removeEmailsdoes not touch native feeds (the data is per-sender, not per-email; deleting emails should not drop a discovered native feed).
src/infrastructure/feed-mapper.ts: toListItemDTO gains a hasNativeFeed
parameter (like pendingConfirmation), projected into FeedListItem. The
repository passes feed.hasNativeFeed() when saving. nativeFeeds /
nativeFeedDismissed persist as part of FeedMetadata (stored directly in KV —
additive, no mapper change for the metadata blob itself).
REST API
src/routes/api/schemas.ts — FeedSchema gains a read-only field:
nativeFeeds: z.array(z.object({
url: z.string(),
type: z.enum(["rss", "atom", "json"]),
})),
Populated from feed.nativeFeeds() in the feed-read handler so an API client can
choose which native feed to subscribe to. Read-only — not accepted on
FeedCreate/FeedUpdate.
Admin UI
Mirror the confirmation surfaces (detail + badge + pill + dismiss).
- Detail (per-feed view,
src/routes/admin/emails.tsx): next to the existingFeedFormats"Subscribe" block (the KTN feeds), render a second group "Native feeds" whennativeFeeds()is non-empty. Each native feed is a copyable chip (type label RSS/Atom/JSON + copy + open-in-new-tab), reusing the existing copyable/chip styling. Net result: KTN feeds on one side, native feeds on the other, both copy-pasteable into a reader. Include a "dismiss" control (POST to the dismiss route) to clear the dashboard/list notice. - List badge (
src/routes/admin.tsxfeed row): a discreet badge whenhasNativeFeed. - Dashboard pill:
pill-native(styled likepill-confirmation) on the dashboard feed list whenhasNativeFeed. - Dismiss route:
POST /admin/feeds/:feedId/native-feed/dismiss→ load aggregate →feed.dismissNativeFeed()→ save → JSON ok. Client script wired like the existingconfirmation-dismiss(insrc/scripts/client/). - Styles: add
.native-feedsgroup +.pill-native+ badge rules insrc/styles/components.css, matching the format-chip / confirmation styling.
Out of scope (v1)
- No change to the public XML/JSON feed output (user decision: native feeds live in admin + REST, not in the rendered feeds).
- No anchor-text heuristics ("Subscribe via RSS" links) —
<link rel=alternate>only, to keep false positives near zero.
Testing
src/domain/native-feed.test.ts— MIME mapping, dedupe, ignores unknown types.src/infrastructure/html-processor.test.ts—extractFeedLinks: rel/type parsing, relative-href absolutization, http(s)-only, plain-text →[].src/domain/feed.aggregate.test.ts— ingest per-sender latest-wins, union getter, re-notify on new URL, dismiss lower-only, removeEmails leaves it intact.src/application/email-processor.test.ts— end-to-end: an email with a<link rel=alternate>populatesnativeFeeds; one without leaves it alone.src/infrastructure/feed-mapper.test.ts/ repository —hasNativeFeedprojection intofeeds:list.src/routes/admin.test.ts— detail "native-feeds" group, list badge, dashboardpill-native, dismiss route clears the flag.- REST API test —
FeedSchema.nativeFeedspresent in the feed-read response.
End green: npx tsc --noEmit, npm test, npm run build.
Docs
README.md/INSTALL.md— mention native-feed detection.docs/index.html(marketing landing) — add a feature card (it's a differentiator we ship before upstream).CLAUDE.md— adddomain/native-feed.tsto the source layout; note the newFeedMetadata.nativeFeeds/nativeFeedDismissedfields.