diff --git a/src/domain/native-feed.test.ts b/src/domain/native-feed.test.ts
new file mode 100644
index 0000000..b77ad19
--- /dev/null
+++ b/src/domain/native-feed.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect } from "vitest";
+import { detectNativeFeeds, unionNativeFeeds } from "./native-feed";
+
+describe("detectNativeFeeds", () => {
+ it("maps the three canonical MIME types to kinds", () => {
+ expect(
+ detectNativeFeeds([
+ { href: "https://x.com/atom", type: "application/atom+xml" },
+ { href: "https://x.com/rss", type: "application/rss+xml" },
+ { href: "https://x.com/json", type: "application/feed+json" },
+ ]),
+ ).toEqual([
+ { url: "https://x.com/atom", type: "atom" },
+ { url: "https://x.com/rss", type: "rss" },
+ { url: "https://x.com/json", type: "json" },
+ ]);
+ });
+
+ it("ignores unknown MIME types (application/json, text/html)", () => {
+ expect(
+ detectNativeFeeds([
+ { href: "https://x.com/api", type: "application/json" },
+ { href: "https://x.com/", type: "text/html" },
+ ]),
+ ).toEqual([]);
+ });
+
+ it("strips MIME parameters and is case-insensitive", () => {
+ expect(
+ detectNativeFeeds([
+ { href: "https://x.com/f", type: "Application/RSS+XML; charset=utf-8" },
+ ]),
+ ).toEqual([{ url: "https://x.com/f", type: "rss" }]);
+ });
+
+ it("dedupes by URL (first kind wins)", () => {
+ expect(
+ detectNativeFeeds([
+ { href: "https://x.com/f", type: "application/rss+xml" },
+ { href: "https://x.com/f", type: "application/atom+xml" },
+ ]),
+ ).toEqual([{ url: "https://x.com/f", type: "rss" }]);
+ });
+});
+
+describe("unionNativeFeeds", () => {
+ it("returns [] for undefined", () => {
+ expect(unionNativeFeeds(undefined)).toEqual([]);
+ });
+
+ it("unions across senders, deduping by URL", () => {
+ expect(
+ unionNativeFeeds({
+ "a@x.com": [{ url: "https://x.com/rss", type: "rss" }],
+ "b@y.com": [
+ { url: "https://x.com/rss", type: "rss" },
+ { url: "https://y.com/atom", type: "atom" },
+ ],
+ }),
+ ).toEqual([
+ { url: "https://x.com/rss", type: "rss" },
+ { url: "https://y.com/atom", type: "atom" },
+ ]);
+ });
+});
diff --git a/src/domain/native-feed.ts b/src/domain/native-feed.ts
new file mode 100644
index 0000000..f494f57
--- /dev/null
+++ b/src/domain/native-feed.ts
@@ -0,0 +1,54 @@
+/**
+ * Pure detection of a newsletter's own syndication feed. No DOM, no I/O — it
+ * receives already-extracted tuples (infra parses the HTML) and decides
+ * which ones are real feeds. This module owns the business knowledge: the strict
+ * set of recognized feed MIME types.
+ */
+import { NativeFeed } from "../types";
+
+// MIME type → feed kind. Strict: only the three canonical syndication types.
+// `application/json` is deliberately excluded — too broad, captures non-feeds.
+const MIME_TO_KIND: Record = {
+ "application/atom+xml": "atom",
+ "application/rss+xml": "rss",
+ "application/feed+json": "json",
+};
+
+// Drop MIME parameters ("; charset=…"), trim, lowercase.
+function normalizeMime(type: string): string {
+ return type.split(";")[0].trim().toLowerCase();
+}
+
+/** Map raw tuples to recognized native feeds, deduped by URL. */
+export function detectNativeFeeds(
+ links: { href: string; type: string }[],
+): NativeFeed[] {
+ const out: NativeFeed[] = [];
+ const seen = new Set();
+ for (const link of links) {
+ const kind = MIME_TO_KIND[normalizeMime(link.type)];
+ if (!kind) continue;
+ const url = link.href.trim();
+ if (!url || seen.has(url)) continue;
+ seen.add(url);
+ out.push({ url, type: kind });
+ }
+ return out;
+}
+
+/** Flatten per-sender native feeds into one list, deduped by URL (first wins). */
+export function unionNativeFeeds(
+ bySender: Record | undefined,
+): NativeFeed[] {
+ if (!bySender) return [];
+ const out: NativeFeed[] = [];
+ const seen = new Set();
+ for (const feeds of Object.values(bySender)) {
+ for (const feed of feeds) {
+ if (seen.has(feed.url)) continue;
+ seen.add(feed.url);
+ out.push({ ...feed });
+ }
+ }
+ return out;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index 2c69cb5..f65801c 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -69,6 +69,13 @@ export interface FeedMetadata {
pendingConfirmation?: boolean;
}
+// A syndication feed a newsletter advertises about itself (via
+// ), as opposed to the KTN-generated feed.
+export interface NativeFeed {
+ url: string;
+ type: "rss" | "atom" | "json";
+}
+
// Email metadata interface (summary info for listing)
export interface EmailMetadata {
key: string;