feat(unsubscribe): RFC 8058 one-click unsubscribe on feed deletion

Capture each sender's List-Unsubscribe one-click URL during ingestion
(stored per sender in feed metadata, mirroring the iconDomain pattern) and
fire one-click POSTs via ctx.waitUntil when a feed is deleted, so newsletters
stop mailing the now-dead address. Tracked with a new unsubscribes_sent
counter surfaced on the status page and /api/stats.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Julien HerrandClaude Opus 4.7 committed 2026-05-23 14:35:05 +02:00
1 parent eb12f21894
commit 3ad0188bc0
14 files changed
+558 -4

No files matched your search

+90
View File
@@ -548,3 +548,93 @@ describe("processEmail — feed icon", () => {
).toMatchObject({ contentType: "image/png" });
});
});
describe("processEmail — unsubscribe capture", () => {
let env: ReturnType<typeof createMockEnv>;
beforeEach(async () => {
env = createMockEnv();
await env.EMAIL_STORAGE.put(
`feed:${VALID_FEED_ID}:config`,
JSON.stringify({}),
);
});
it("stores the one-click unsubscribe URL on the feed metadata, keyed by sender", async () => {
await processEmail(
makeInput({
senders: ["[email protected]"],
headers: {
"list-unsubscribe": "<https://example.com/u?t=abc>",
"list-unsubscribe-post": "List-Unsubscribe=One-Click",
},
}),
env as any,
);
const metadata = (await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
)) as { unsubscribe?: Record<string, string> };
expect(metadata.unsubscribe).toEqual({
"[email protected]": "https://example.com/u?t=abc",
});
});
it("keeps one entry per sender and overwrites with the latest URL", async () => {
await processEmail(
makeInput({
senders: ["[email protected]"],
headers: {
"list-unsubscribe": "<https://one.com/u/1>",
"list-unsubscribe-post": "List-Unsubscribe=One-Click",
},
}),
env as any,
);
await processEmail(
makeInput({
senders: ["[email protected]"],
headers: {
"list-unsubscribe": "<https://two.com/u/1>",
"list-unsubscribe-post": "List-Unsubscribe=One-Click",
},
}),
env as any,
);
await processEmail(
makeInput({
senders: ["[email protected]"],
headers: {
"list-unsubscribe": "<https://one.com/u/2>",
"list-unsubscribe-post": "List-Unsubscribe=One-Click",
},
}),
env as any,
);
const metadata = (await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
)) as { unsubscribe?: Record<string, string> };
expect(metadata.unsubscribe).toEqual({
"[email protected]": "https://one.com/u/2",
"[email protected]": "https://two.com/u/1",
});
});
it("does not store anything without the one-click Post header", async () => {
await processEmail(
makeInput({
headers: { "list-unsubscribe": "<https://example.com/u/1>" },
}),
env as any,
);
const metadata = (await env.EMAIL_STORAGE.get(
`feed:${VALID_FEED_ID}:metadata`,
"json",
)) as { unsubscribe?: Record<string, string> };
expect(metadata.unsubscribe).toBeUndefined();
});
});