From 79bb4902b95d58ae3a34aac6a6cf9d07c4671cf4 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Mon, 25 May 2026 08:58:26 +0200 Subject: [PATCH] feat(domain): pendingConfirmation flag on the Feed aggregate --- src/domain/feed.aggregate.test.ts | 76 +++++++++++++++++++++++++++++++ src/domain/feed.aggregate.ts | 19 ++++++++ src/types/index.ts | 8 ++++ 3 files changed, 103 insertions(+) diff --git a/src/domain/feed.aggregate.test.ts b/src/domain/feed.aggregate.test.ts index ebaab0f..cdb93b6 100644 --- a/src/domain/feed.aggregate.test.ts +++ b/src/domain/feed.aggregate.test.ts @@ -233,6 +233,82 @@ describe("Feed events", () => { }); }); +function newFeed(): Feed { + return Feed.create( + FeedId.generate(), + { + title: "T", + description: "", + language: "en", + allowedSenders: [], + blockedSenders: [], + }, + { mailboxId: MailboxId.unchecked("alpha.beta.10") }, + ); +} + +function confirmationEmail( + key: string, + confirmation?: { links: string[] }, +): EmailMetadata { + return { + key, + subject: "s", + receivedAt: Date.now(), + size: 10, + ...(confirmation ? { confirmation } : {}), + }; +} + +describe("Feed pendingConfirmation", () => { + it("is false on a fresh feed", () => { + expect(newFeed().pendingConfirmation).toBe(false); + }); + + it("is raised when a confirmation email is ingested", () => { + const feed = newFeed(); + feed.ingest(confirmationEmail("k1", { links: ["https://x/confirm"] }), { + maxBytes: 1_000_000, + }); + expect(feed.pendingConfirmation).toBe(true); + }); + + it("stays false for a non-confirmation email", () => { + const feed = newFeed(); + feed.ingest(confirmationEmail("k1"), { maxBytes: 1_000_000 }); + expect(feed.pendingConfirmation).toBe(false); + }); + + it("is cleared by dismissConfirmation", () => { + const feed = newFeed(); + feed.ingest(confirmationEmail("k1", { links: ["https://x/confirm"] }), { + maxBytes: 1_000_000, + }); + feed.dismissConfirmation(); + expect(feed.pendingConfirmation).toBe(false); + }); + + it("does not re-raise after dismiss when removing an unrelated email", () => { + const feed = newFeed(); + feed.ingest(confirmationEmail("k1", { links: ["https://x/confirm"] }), { + maxBytes: 1_000_000, + }); + feed.ingest(confirmationEmail("k2"), { maxBytes: 1_000_000 }); + feed.dismissConfirmation(); + feed.removeEmails(["k2"]); + expect(feed.pendingConfirmation).toBe(false); + }); + + it("clears when the last confirmation email is removed", () => { + const feed = newFeed(); + feed.ingest(confirmationEmail("k1", { links: ["https://x/confirm"] }), { + maxBytes: 1_000_000, + }); + feed.removeEmails(["k1"]); + expect(feed.pendingConfirmation).toBe(false); + }); +}); + describe("FeedRepository.load / save round-trip", () => { it("persists a created feed and reflects later mutations", async () => { const repo = new FeedRepository(mockEnv().EMAIL_STORAGE); diff --git a/src/domain/feed.aggregate.ts b/src/domain/feed.aggregate.ts index ba04118..6779e97 100644 --- a/src/domain/feed.aggregate.ts +++ b/src/domain/feed.aggregate.ts @@ -155,6 +155,11 @@ export class Feed { return this._metadata.iconDomain; } + /** True while at least one unactioned confirmation email is present. */ + get pendingConfirmation(): boolean { + return this._metadata.pendingConfirmation ?? false; + } + allowedSenders(): string[] { return [...this._state.allowedSenders]; } @@ -258,6 +263,10 @@ export class Feed { }; } + if (entry.confirmation) { + this._metadata.pendingConfirmation = true; + } + this._events.push({ type: "EmailIngested", feedId: this.id, @@ -295,9 +304,19 @@ export class Feed { (target.has(entry.key) ? removed : kept).push(entry); } this._metadata.emails = kept; + // Lower-only: clear when no confirmation email remains. Never re-raise here, + // so an admin "dismiss" survives deletion of unrelated emails. + if (!kept.some((e) => e.confirmation)) { + this._metadata.pendingConfirmation = false; + } return { removed }; } + /** Mark the pending confirmation as handled — "stop reminding me". */ + dismissConfirmation(): void { + this._metadata.pendingConfirmation = false; + } + /** * 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 8ee76ee..1e59359 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -61,6 +61,10 @@ export interface FeedMetadata { // RFC 8058 one-click unsubscribe URLs, keyed by sender so each newsletter on // the feed keeps its own (latest) link; fired when the feed is deleted. unsubscribe?: Record; + // True while at least one unactioned confirmation email is present. Raised on + // ingest, lowered by an admin "dismiss" or when the last confirmation email is + // removed. Projected into feeds:list for the dashboard. + pendingConfirmation?: boolean; } // Email metadata interface (summary info for listing) @@ -73,6 +77,9 @@ export interface EmailMetadata { inlineAttachmentIds?: string[]; // Inline images: hidden from lists, still cleaned up messageId?: string; // RFC 2822 Message-ID header (dedup primary key) dedupHash?: string; // SHA-256 hex of normalized subject+content (dedup fallback) + // Detected subscription-confirmation links (ranked top-3). Present ⇒ the email + // was detected as a confirmation request. + confirmation?: { links: string[] }; } // Feed list interface @@ -87,6 +94,7 @@ export interface FeedListItem { description?: string; 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 } // Cumulative monitoring counters (persisted as a KV singleton)