diff --git a/src/domain/feed.aggregate.test.ts b/src/domain/feed.aggregate.test.ts
index cdb93b6..f8fb80d 100644
--- a/src/domain/feed.aggregate.test.ts
+++ b/src/domain/feed.aggregate.test.ts
@@ -338,3 +338,76 @@ describe("FeedRepository.load / save round-trip", () => {
expect(await repo.load(FeedId.unchecked("missing"))).toBeNull();
});
});
+
+describe("Feed native feeds", () => {
+ const nf = (
+ senderKey: string,
+ url: string,
+ type: "rss" | "atom" | "json",
+ ) => ({
+ maxBytes: 1_000_000_000,
+ nativeFeeds: { senderKey, feeds: [{ url, type }] },
+ });
+
+ it("stores native feeds and raises the flag on ingest", () => {
+ const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
+ feed.ingest(entry(), nf("a@x.com", "https://x.com/rss", "rss"));
+ expect(feed.nativeFeeds()).toEqual([
+ { url: "https://x.com/rss", type: "rss" },
+ ]);
+ expect(feed.hasNativeFeed()).toBe(true);
+ });
+
+ it("latest non-empty wins per sender; other senders preserved", () => {
+ const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
+ feed.ingest(
+ entry({ key: "k1" }),
+ nf("a@x.com", "https://x.com/old", "rss"),
+ );
+ feed.ingest(
+ entry({ key: "k2" }),
+ nf("b@y.com", "https://y.com/atom", "atom"),
+ );
+ feed.ingest(
+ entry({ key: "k3" }),
+ nf("a@x.com", "https://x.com/new", "rss"),
+ );
+ expect(feed.nativeFeeds()).toEqual([
+ { url: "https://x.com/new", type: "rss" },
+ { url: "https://y.com/atom", type: "atom" },
+ ]);
+ });
+
+ it("dismiss hides the notice but keeps URLs; only a new URL re-raises", () => {
+ const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
+ feed.ingest(
+ entry({ key: "k1" }),
+ nf("a@x.com", "https://x.com/rss", "rss"),
+ );
+ feed.dismissNativeFeed();
+ expect(feed.hasNativeFeed()).toBe(false);
+ expect(feed.nativeFeeds()).toHaveLength(1);
+ feed.ingest(
+ entry({ key: "k2" }),
+ nf("a@x.com", "https://x.com/rss", "rss"),
+ );
+ expect(feed.hasNativeFeed()).toBe(false); // same URL → stays dismissed
+ feed.ingest(
+ entry({ key: "k3" }),
+ nf("a@x.com", "https://x.com/rss2", "rss"),
+ );
+ expect(feed.hasNativeFeed()).toBe(true); // new URL → re-raise
+ });
+
+ it("removeEmails leaves native feeds intact", () => {
+ const feed = Feed.create(FID, createInput(), { mailboxId: MBOX });
+ feed.ingest(
+ entry({ key: "k1" }),
+ nf("a@x.com", "https://x.com/rss", "rss"),
+ );
+ feed.removeEmails(["k1"]);
+ expect(feed.nativeFeeds()).toEqual([
+ { url: "https://x.com/rss", type: "rss" },
+ ]);
+ });
+});
diff --git a/src/domain/feed.aggregate.ts b/src/domain/feed.aggregate.ts
index 320025c..425f604 100644
--- a/src/domain/feed.aggregate.ts
+++ b/src/domain/feed.aggregate.ts
@@ -1,4 +1,4 @@
-import { FeedMetadata, EmailMetadata } from "../types";
+import { FeedMetadata, EmailMetadata, NativeFeed } from "../types";
import { FeedState } from "./feed-state";
import { FeedId } from "./value-objects/feed-id";
import { MailboxId } from "./value-objects/mailbox-id";
@@ -6,6 +6,7 @@ import { Lifetime } from "./value-objects/lifetime";
import { SenderPolicy, SenderDecision } from "./value-objects/sender-policy";
import { Clock, systemClock } from "./clock";
import { FeedEvent } from "./events";
+import { unionNativeFeeds } from "./native-feed";
export interface CreateFeedInput {
title: string;
@@ -57,6 +58,8 @@ export interface IngestOptions {
iconDomain?: string;
/** RFC 8058 one-click unsubscribe link, keyed by the sending newsletter. */
unsub?: { senderKey: string; url: string };
+ /** Native syndication feeds the sender advertised, keyed by sender. */
+ nativeFeeds?: { senderKey: string; feeds: NativeFeed[] };
}
/**
@@ -164,6 +167,16 @@ export class Feed {
return this._metadata.pendingConfirmation ?? false;
}
+ /** Discovered native feeds (Atom/RSS/JSON), union across senders, deduped. */
+ nativeFeeds(): NativeFeed[] {
+ return unionNativeFeeds(this._metadata.nativeFeeds);
+ }
+
+ /** True when a native feed was discovered and the notice was not dismissed. */
+ hasNativeFeed(): boolean {
+ return this.nativeFeeds().length > 0 && !this._metadata.nativeFeedDismissed;
+ }
+
allowedSenders(): string[] {
return [...this._state.allowedSenders];
}
@@ -271,6 +284,19 @@ export class Feed {
this._metadata.pendingConfirmation = true;
}
+ if (opts.nativeFeeds && opts.nativeFeeds.feeds.length > 0) {
+ const known = new Set(this.nativeFeeds().map((f) => f.url));
+ this._metadata.nativeFeeds = {
+ ...(this._metadata.nativeFeeds ?? {}),
+ [opts.nativeFeeds.senderKey]: opts.nativeFeeds.feeds,
+ };
+ // Re-raise the notice only when a genuinely new URL appears, so a dismiss
+ // survives the same feed being re-advertised on every subsequent email.
+ if (opts.nativeFeeds.feeds.some((f) => !known.has(f.url))) {
+ this._metadata.nativeFeedDismissed = false;
+ }
+ }
+
this._events.push({
type: "EmailIngested",
feedId: this.id,
@@ -321,6 +347,11 @@ export class Feed {
this._metadata.pendingConfirmation = false;
}
+ /** Mark the native-feed notice as handled — "stop reminding me". */
+ dismissNativeFeed(): void {
+ this._metadata.nativeFeedDismissed = true;
+ }
+
/**
* The single edit path. Apply the patch (only the fields it carries) and
* recompute expiry when the application supplies a `Lifetime` — an absent
diff --git a/src/types/index.ts b/src/types/index.ts
index f65801c..5f6a4e6 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -67,6 +67,12 @@ export interface FeedMetadata {
// ingest, lowered by an admin "dismiss" or when the last confirmation email is
// removed. Projected into feeds:list for the dashboard.
pendingConfirmation?: boolean;
+ // Native syndication feeds (Atom/RSS/JSON) senders advertised via
+ // , keyed by sender. Latest non-empty per sender wins.
+ nativeFeeds?: Record;
+ // True when the admin dismissed the native-feed notice; suppresses the
+ // dashboard pill while the URLs stay available in the feed detail view.
+ nativeFeedDismissed?: boolean;
}
// A syndication feed a newsletter advertises about itself (via
@@ -104,6 +110,7 @@ export interface FeedListItem {
mailbox_id: string; // Cached inbound address local part (admin/API display)
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
pendingConfirmation?: boolean; // Projected from FeedMetadata for the dashboard
+ hasNativeFeed?: boolean; // Projected from FeedMetadata for the dashboard pill
}
// Cumulative monitoring counters (persisted as a KV singleton)