From 94a2dbcd9830e00555187ffa059b832f3a681d39 Mon Sep 17 00:00:00 2001 From: hhbb958 Date: Sat, 17 Oct 2026 12:22:19 +0000 Subject: [PATCH] fix(inbox): keep the List-Unsubscribe target of a later message in a scan batch --- .../src/inbox/unsubscribe-service.ts | 16 ++++++ .../plugin-inbox/test/inbox.real-db.test.ts | 57 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/plugins/plugin-inbox/src/inbox/unsubscribe-service.ts b/plugins/plugin-inbox/src/inbox/unsubscribe-service.ts index 0b0a56b44b9cc..12f43b892e2be 100644 --- a/plugins/plugin-inbox/src/inbox/unsubscribe-service.ts +++ b/plugins/plugin-inbox/src/inbox/unsubscribe-service.ts @@ -361,6 +361,22 @@ export class InboxUnsubscribeService { existing.messageCount += 1; existing.allMessageIds.push(message.id); existing.allThreadIds.push(message.threadId); + // A sender does not publish List-Unsubscribe on every message (a + // one-off "your order shipped" can be newer than the newsletter that + // carries it), so keep the first mechanism the batch publishes instead + // of only the one on the first message. The method and its headers are + // taken from the same message so the reported method never disagrees + // with the stored target. + if ( + !existing.unsubscribeHttpUrl && + !existing.unsubscribeMailto && + (options.httpUrl || options.mailto) + ) { + existing.unsubscribeMethod = method; + existing.unsubscribeHttpUrl = options.httpUrl; + existing.unsubscribeMailto = options.mailto; + existing.listUnsubscribePost = listUnsubscribePost; + } // Gmail lists newest-first, so derive the extremes by timestamp rather // than iteration order. Unparseable timestamps never become an extreme. const receivedAtMs = Date.parse(message.receivedAt); diff --git a/plugins/plugin-inbox/test/inbox.real-db.test.ts b/plugins/plugin-inbox/test/inbox.real-db.test.ts index 7742f28a7bee6..7ed5a2a030397 100644 --- a/plugins/plugin-inbox/test/inbox.real-db.test.ts +++ b/plugins/plugin-inbox/test/inbox.real-db.test.ts @@ -362,6 +362,63 @@ describe("InboxService + InboxRepository — real PGLite", () => { }); }); + it("keeps the List-Unsubscribe target of a message that is not the batch's first", async () => { + const gmail = { + requireGmailGrant: async () => + ({ + id: "grant-1", + connectorAccountId: "account-1", + capabilities: ["google.gmail.triage"], + }) as Awaited>, + searchGmail: async () => + ({ + syncedAt: "2026-10-11T00:00:00.000Z", + messages: [ + { + // Gmail lists newest-first. This sender publishes + // List-Unsubscribe on its campaign mail, not on every message. + id: "unsubscribe-message-newest", + threadId: "unsubscribe-thread-newest", + from: "Shop ", + fromEmail: "shop@example.com", + subject: "Your order shipped", + receivedAt: "2026-10-11T00:00:00.000Z", + metadata: { headers: {} }, + }, + { + id: "unsubscribe-message-older", + threadId: "unsubscribe-thread-older", + from: "Shop ", + fromEmail: "shop@example.com", + subject: "Weekly offer", + receivedAt: "2026-10-09T00:00:00.000Z", + metadata: { + headers: { + "List-Unsubscribe": + "", + }, + }, + }, + ], + }) as Awaited>, + sendMailtoUnsubscribeEmail: vi.fn(), + createGmailFilterForSender: vi.fn(async () => ({ filterId: null })), + trashGmailThread: vi.fn(), + } satisfies InboxGmailGateway; + const unsubscribe = new InboxUnsubscribeService(runtime, { gmail }); + + const scan = await unsubscribe.scanEmailSubscriptions({ maxMessages: 2 }); + + expect(scan.senders).toHaveLength(1); + expect(scan.senders[0]).toMatchObject({ + senderEmail: "shop@example.com", + messageCount: 2, + unsubscribeMethod: "mailto", + unsubscribeMailto: "mailto:leave@lists.example.com?subject=unsubscribe", + }); + expect(scan.summary.manualOnlyCount).toBe(0); + }); + it("dedups one source message against the real DB on re-triage", async () => { const first = await service.triage([ inbound({ id: "msg-dedup", text: "Can you confirm the meeting?" }),