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)