Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions plugins/plugin-inbox/src/inbox/unsubscribe-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
57 changes: 57 additions & 0 deletions plugins/plugin-inbox/test/inbox.real-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<InboxGmailGateway["requireGmailGrant"]>>,
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 <shop@example.com>",
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 <shop@example.com>",
fromEmail: "shop@example.com",
subject: "Weekly offer",
receivedAt: "2026-10-09T00:00:00.000Z",
metadata: {
headers: {
"List-Unsubscribe":
"<mailto:leave@lists.example.com?subject=unsubscribe>",
},
},
},
],
}) as Awaited<ReturnType<InboxGmailGateway["searchGmail"]>>,
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?" }),
Expand Down