From 3ad0188bc08c10c1914bc00fa4c4586102be64e9 Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Sat, 23 May 2026 14:35:05 +0200 Subject: [PATCH] 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 --- README.md | 1 + TODO.md | 2 +- docs/index.html | 8 ++ src/config/constants.ts | 3 + src/lib/email-processor.test.ts | 90 ++++++++++++++++ src/lib/email-processor.ts | 14 +++ src/routes/admin.test.ts | 117 +++++++++++++++++++- src/routes/admin/feeds.tsx | 29 ++++- src/routes/admin/helpers.ts | 24 ++++- src/routes/home.tsx | 1 + src/types/index.ts | 4 + src/utils/stats.ts | 2 + src/utils/unsubscribe.test.ts | 184 ++++++++++++++++++++++++++++++++ src/utils/unsubscribe.ts | 83 ++++++++++++++ 14 files changed, 558 insertions(+), 4 deletions(-) create mode 100644 src/utils/unsubscribe.test.ts create mode 100644 src/utils/unsubscribe.ts diff --git a/README.md b/README.md index 15f189a..5d2365f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ kill-the-news keeps the same workflow while avoiding shared domains and shared d - RSS generation on demand (`/rss/:feedId`) - Atom feed at `/atom/:feedId` - Per-feed favicon derived from the last sender's domain (`/favicon/:feedId`), cached and shown in feeds + admin +- Automatic RFC 8058 one-click unsubscribe when a feed is deleted — stops newsletters from mailing the now-dead address - Email attachments stored in Cloudflare R2 and exposed as RSS enclosures (optional) - Cloudflare KV storage for feed config + email metadata/content - Password-protected admin UI diff --git a/TODO.md b/TODO.md index 7c422a9..0b21e9b 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,7 @@ Feature gaps identified by comparing with [kill-the-newsletter](https://github.c - [x] **Per-feed favicon from the last sender's domain** — give each feed an icon by fetching the favicon of the last sender's domain, so feeds are visually distinguishable in readers and the admin UI. Resolve the domain from the most recent email's `from`, fetch its favicon (e.g. `https:///favicon.ico` or a parsed ``, with a fallback service), and cache the result aggressively (KV/R2 + Cache API with a long TTL) so it isn't re-fetched on every request. Expose it via the RSS `` / Atom `` and the admin feed list. -- [ ] **RFC 8058 one-click unsubscribe on feed deletion** — when a feed is deleted, automatically unsubscribe from the newsletters that fed it so messages stop arriving at the now-dead address. Parse and store the `List-Unsubscribe` / `List-Unsubscribe-Post` headers ([RFC 8058](https://www.rfc-editor.org/rfc/rfc8058.txt)) from incoming emails, then on deletion POST `List-Unsubscribe=One-Click` to each stored unsubscribe URL. Requires capturing the headers during ingestion (`src/lib/email-processor.ts`) and firing the outbound requests from the feed-delete paths (`src/routes/admin/feeds.tsx`), ideally via `ctx.waitUntil`. +- [x] **RFC 8058 one-click unsubscribe on feed deletion** — when a feed is deleted, automatically unsubscribe from the newsletters that fed it so messages stop arriving at the now-dead address. Parse and store the `List-Unsubscribe` / `List-Unsubscribe-Post` headers ([RFC 8058](https://www.rfc-editor.org/rfc/rfc8058.txt)) from incoming emails, then on deletion POST `List-Unsubscribe=One-Click` to each stored unsubscribe URL. Requires capturing the headers during ingestion (`src/lib/email-processor.ts`) and firing the outbound requests from the feed-delete paths (`src/routes/admin/feeds.tsx`), ideally via `ctx.waitUntil`. ## Heavy diff --git a/docs/index.html b/docs/index.html index 19dca5d..2aaa4d2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -816,6 +816,14 @@

Each feed picks up the favicon of its newsletter's sender domain, so feeds are easy to tell apart in your reader and the admin UI.

+
+
+ +
+

Auto-Unsubscribe on Delete

+

Deleting a feed fires RFC 8058 one-click unsubscribe requests to its newsletters, so the messages stop arriving at the now-dead address.

+
+
diff --git a/src/config/constants.ts b/src/config/constants.ts index 6d9906e..181bdcb 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -33,3 +33,6 @@ export const MAX_ICON_BYTES = 100 * 1024; // 100 KB /** Timeout for an outbound favicon fetch (milliseconds). */ export const ICON_FETCH_TIMEOUT_MS = 5000; + +/** Timeout for an outbound RFC 8058 one-click unsubscribe request (milliseconds). */ +export const UNSUBSCRIBE_TIMEOUT_MS = 5000; diff --git a/src/lib/email-processor.test.ts b/src/lib/email-processor.test.ts index 8c7f2d9..c14689f 100644 --- a/src/lib/email-processor.test.ts +++ b/src/lib/email-processor.test.ts @@ -548,3 +548,93 @@ describe("processEmail — feed icon", () => { ).toMatchObject({ contentType: "image/png" }); }); }); + +describe("processEmail — unsubscribe capture", () => { + let env: ReturnType; + + 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: ["news@example.com"], + headers: { + "list-unsubscribe": "", + "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 }; + expect(metadata.unsubscribe).toEqual({ + "news@example.com": "https://example.com/u?t=abc", + }); + }); + + it("keeps one entry per sender and overwrites with the latest URL", async () => { + await processEmail( + makeInput({ + senders: ["a@one.com"], + headers: { + "list-unsubscribe": "", + "list-unsubscribe-post": "List-Unsubscribe=One-Click", + }, + }), + env as any, + ); + await processEmail( + makeInput({ + senders: ["b@two.com"], + headers: { + "list-unsubscribe": "", + "list-unsubscribe-post": "List-Unsubscribe=One-Click", + }, + }), + env as any, + ); + await processEmail( + makeInput({ + senders: ["a@one.com"], + headers: { + "list-unsubscribe": "", + "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 }; + expect(metadata.unsubscribe).toEqual({ + "a@one.com": "https://one.com/u/2", + "b@two.com": "https://two.com/u/1", + }); + }); + + it("does not store anything without the one-click Post header", async () => { + await processEmail( + makeInput({ + headers: { "list-unsubscribe": "" }, + }), + env as any, + ); + + const metadata = (await env.EMAIL_STORAGE.get( + `feed:${VALID_FEED_ID}:metadata`, + "json", + )) as { unsubscribe?: Record }; + expect(metadata.unsubscribe).toBeUndefined(); + }); +}); diff --git a/src/lib/email-processor.ts b/src/lib/email-processor.ts index 9b69899..2e131e7 100644 --- a/src/lib/email-processor.ts +++ b/src/lib/email-processor.ts @@ -12,6 +12,7 @@ import { cacheFaviconForDomain, extractEmailDomain, } from "../utils/favicon-fetcher"; +import { parseOneClickUnsubscribe } from "../utils/unsubscribe"; import { logger } from "./logger"; import { FEED_MAX_BYTES } from "../config/constants"; @@ -223,6 +224,19 @@ export async function storeEmail( feedMetadata.iconDomain = iconDomain; } + // Capture the sender's RFC 8058 one-click unsubscribe link so we can stop the + // newsletter when the feed is deleted. Keyed by sender: each newsletter on the + // feed keeps its own entry, and a repeat send overwrites with the latest URL. + const unsubUrl = parseOneClickUnsubscribe(input.headers ?? {}); + if (unsubUrl) { + const senderKey = + input.senders[0] || extractEmailDomain(input.from) || input.from; + feedMetadata.unsubscribe = { + ...(feedMetadata.unsubscribe ?? {}), + [senderKey]: unsubUrl, + }; + } + let totalSize = feedMetadata.emails.reduce( (sum, e) => sum + (e.size ?? 0), 0, diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index aef75dd..2963c4d 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { http, HttpResponse } from "msw"; import { Hono } from "hono"; import app from "./admin"; -import { createMockEnv } from "../test/setup"; +import { createMockEnv, server } from "../test/setup"; +import { getCounters } from "../utils/stats"; import { Env } from "../types"; describe("Admin Routes", () => { @@ -328,6 +330,119 @@ describe("Admin Routes", () => { expect(payload.feedId).toBe(feedId); }); + it("fires one-click unsubscribe requests on feed deletion and bumps the counter", async () => { + const authCookie = await loginAndGetCookie(); + const formData = new FormData(); + formData.append("title", "Unsub Feed"); + + await request("/admin/feeds/create", { + method: "POST", + headers: { + Cookie: authCookie, + Origin: "https://test.getmynews.app", + }, + body: formData, + }); + + const feedList = (await mockEnv.EMAIL_STORAGE.get( + "feeds:list", + "json", + )) as { + feeds: Array<{ id: string }>; + } | null; + const feedId = feedList?.feeds[0].id as string; + + // Simulate an ingested email having captured an unsubscribe URL. + await mockEnv.EMAIL_STORAGE.put( + `feed:${feedId}:metadata`, + JSON.stringify({ + emails: [], + unsubscribe: { "news@example.com": "https://example.com/u/1" }, + }), + ); + + let unsubHit = false; + server.use( + http.post("https://example.com/u/1", () => { + unsubHit = true; + return HttpResponse.text("ok"); + }), + ); + + const pending: Promise[] = []; + const ctx = { + waitUntil: (p: Promise) => pending.push(p), + passThroughOnException: () => {}, + } as unknown as ExecutionContext; + + const deleteRes = await testApp.request( + `/admin/feeds/${feedId}/delete`, + { + method: "POST", + headers: { + Cookie: authCookie, + Origin: "https://test.getmynews.app", + }, + }, + mockEnv, + ctx, + ); + expect(deleteRes.status).toBe(302); + + await Promise.all(pending); + + expect(unsubHit).toBe(true); + const counters = await getCounters(mockEnv.EMAIL_STORAGE); + expect(counters.unsubscribes_sent).toBe(1); + }); + + it("sends no unsubscribe requests when the feed has none", async () => { + const authCookie = await loginAndGetCookie(); + const formData = new FormData(); + formData.append("title", "No Unsub Feed"); + + await request("/admin/feeds/create", { + method: "POST", + headers: { + Cookie: authCookie, + Origin: "https://test.getmynews.app", + }, + body: formData, + }); + + const feedList = (await mockEnv.EMAIL_STORAGE.get( + "feeds:list", + "json", + )) as { + feeds: Array<{ id: string }>; + } | null; + const feedId = feedList?.feeds[0].id as string; + + const pending: Promise[] = []; + const ctx = { + waitUntil: (p: Promise) => pending.push(p), + passThroughOnException: () => {}, + } as unknown as ExecutionContext; + + await testApp.request( + `/admin/feeds/${feedId}/delete`, + { + method: "POST", + headers: { + Cookie: authCookie, + Origin: "https://test.getmynews.app", + }, + }, + mockEnv, + ctx, + ); + + await Promise.all(pending); + + const counters = await getCounters(mockEnv.EMAIL_STORAGE); + expect(counters.unsubscribes_sent).toBe(0); + }); + it("should allow bulk feed deletion with valid authentication", async () => { const authCookie = await loginAndGetCookie(); diff --git a/src/routes/admin/feeds.tsx b/src/routes/admin/feeds.tsx index d6be12b..fae9d6a 100644 --- a/src/routes/admin/feeds.tsx +++ b/src/routes/admin/feeds.tsx @@ -6,6 +6,7 @@ import { bumpCounters } from "../../utils/stats"; import { waitUntilSafe } from "../../utils/worker"; import { feedRssUrl, feedEmailAddress } from "../../utils/urls"; import { logger } from "../../lib/logger"; +import { sendUnsubscribes } from "../../utils/unsubscribe"; import { Layout } from "./ui"; import { addFeedToList, @@ -13,6 +14,7 @@ import { removeFeedFromList, removeFeedsFromListBulk, purgeFeedKeysStep, + collectUnsubscribeUrls, } from "./helpers"; type AppEnv = { Bindings: Env }; @@ -537,12 +539,19 @@ feedsRouter.post("/:feedId/delete", async (c) => { const wantsJson = (c.req.header("Accept") || "").includes("application/json"); try { + // Read unsubscribe URLs before the metadata is deleted below. + const unsubscribeUrls = await collectUnsubscribeUrls(emailStorage, feedId); + await deleteFeedFast(emailStorage, feedId); const removed = await removeFeedFromList(emailStorage, feedId); if (removed) { await bumpCounters(emailStorage, { feeds_deleted: 1 }); } + if (unsubscribeUrls.length > 0) { + waitUntilSafe(c, sendUnsubscribes(unsubscribeUrls, env)); + } + waitUntilSafe( c, purgeFeedKeysStep(emailStorage, feedId, { @@ -638,9 +647,12 @@ feedsRouter.post("/bulk-delete", async (c) => { const okIds: string[] = []; const failures: Array<{ feedId: string; error: string }> = []; const warnings: Array<{ feedId: string; warning: string }> = []; + const unsubscribeUrls: string[] = []; for (const feedId of parsedFeedIds) { try { + // Read unsubscribe URLs before the feed metadata is deleted. + const urls = await collectUnsubscribeUrls(emailStorage, feedId); const result = await deleteFeedFastDetailed(emailStorage, feedId); if (!result.ok) { failures.push({ @@ -660,6 +672,7 @@ feedsRouter.post("/bulk-delete", async (c) => { }); } + unsubscribeUrls.push(...urls); okIds.push(feedId); } catch (error) { logger.error("Error bulk deleting feed", { @@ -677,6 +690,10 @@ feedsRouter.post("/bulk-delete", async (c) => { }); } + if (unsubscribeUrls.length > 0) { + waitUntilSafe(c, sendUnsubscribes(unsubscribeUrls, env)); + } + const removed = new Set(deletedFeedIds); okIds.forEach((feedId) => { if (!removed.has(feedId)) { @@ -711,11 +728,17 @@ feedsRouter.post("/bulk-delete", async (c) => { } const okIds: string[] = []; + const unsubscribeUrls: string[] = []; for (const feedId of parsedFeedIds) { try { + // Read unsubscribe URLs before the feed metadata is deleted. + const urls = await collectUnsubscribeUrls(emailStorage, feedId); const result = await deleteFeedFastDetailed(emailStorage, feedId); - if (result.ok) okIds.push(feedId); + if (result.ok) { + unsubscribeUrls.push(...urls); + okIds.push(feedId); + } } catch (error) { logger.error("Error bulk deleting feed", { feedId, @@ -731,6 +754,10 @@ feedsRouter.post("/bulk-delete", async (c) => { }); } + if (unsubscribeUrls.length > 0) { + waitUntilSafe(c, sendUnsubscribes(unsubscribeUrls, env)); + } + return c.redirect( `${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`, ); diff --git a/src/routes/admin/helpers.ts b/src/routes/admin/helpers.ts index 19e4763..c21da69 100644 --- a/src/routes/admin/helpers.ts +++ b/src/routes/admin/helpers.ts @@ -1,4 +1,4 @@ -import { EmailData, FeedList, FeedListItem } from "../../types"; +import { EmailData, FeedList, FeedListItem, FeedMetadata } from "../../types"; import { FEEDS_LIST_KEY } from "../../config/constants"; import { logger } from "../../lib/logger"; @@ -132,6 +132,28 @@ export async function removeFeedFromList( return removed.includes(feedId); } +/** + * Read a feed's stored RFC 8058 one-click unsubscribe URLs (one per sender). + * Must be called before the feed metadata is deleted. Never throws. + */ +export async function collectUnsubscribeUrls( + emailStorage: KVNamespace, + feedId: string, +): Promise { + try { + const metadata = (await emailStorage.get(`feed:${feedId}:metadata`, { + type: "json", + })) as FeedMetadata | null; + return Object.values(metadata?.unsubscribe ?? {}); + } catch (error) { + logger.error("Error reading unsubscribe URLs", { + feedId, + error: String(error), + }); + return []; + } +} + export async function purgeFeedKeysStep( emailStorage: KVNamespace, feedId: string, diff --git a/src/routes/home.tsx b/src/routes/home.tsx index 89c99b9..ae140a5 100644 --- a/src/routes/home.tsx +++ b/src/routes/home.tsx @@ -119,6 +119,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise { tone="danger" /> + ; } // Email metadata interface (summary info for listing) @@ -76,6 +79,7 @@ export interface Counters { feeds_deleted: number; emails_received: number; emails_rejected: number; + unsubscribes_sent: number; last_email_at?: string; // ISO 8601 last_feed_created_at?: string; // ISO 8601 first_seen?: string; // ISO 8601 — first time counters were written (instance start) diff --git a/src/utils/stats.ts b/src/utils/stats.ts index 39dd1e5..cb3fc6a 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -8,6 +8,7 @@ const EMPTY_COUNTERS: Counters = { feeds_deleted: 0, emails_received: 0, emails_rejected: 0, + unsubscribes_sent: 0, }; export async function getCounters(kv: KVNamespace): Promise { @@ -39,6 +40,7 @@ export async function bumpCounters( current.feeds_deleted += changes.feeds_deleted ?? 0; current.emails_received += changes.emails_received ?? 0; current.emails_rejected += changes.emails_rejected ?? 0; + current.unsubscribes_sent += changes.unsubscribes_sent ?? 0; if (changes.last_email_at) current.last_email_at = changes.last_email_at; if (changes.last_feed_created_at) current.last_feed_created_at = changes.last_feed_created_at; diff --git a/src/utils/unsubscribe.test.ts b/src/utils/unsubscribe.test.ts new file mode 100644 index 0000000..78f8634 --- /dev/null +++ b/src/utils/unsubscribe.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from "vitest"; +import { http, HttpResponse } from "msw"; +import { server, createMockEnv } from "../test/setup"; +import { + parseOneClickUnsubscribe, + sendOneClickUnsubscribe, + sendUnsubscribes, +} from "./unsubscribe"; +import { getCounters } from "./stats"; +import type { Env } from "../types"; + +const POST_HEADER = "List-Unsubscribe=One-Click"; + +describe("parseOneClickUnsubscribe", () => { + it("returns the https URL when the one-click Post header is present", () => { + expect( + parseOneClickUnsubscribe({ + "list-unsubscribe": "", + "list-unsubscribe-post": POST_HEADER, + }), + ).toBe("https://news.example.com/u?t=abc"); + }); + + it("prefers the https URL when both https and mailto are present", () => { + expect( + parseOneClickUnsubscribe({ + "list-unsubscribe": + ", ", + "list-unsubscribe-post": POST_HEADER, + }), + ).toBe("https://example.com/u/1"); + }); + + it("returns null for a mailto-only header", () => { + expect( + parseOneClickUnsubscribe({ + "list-unsubscribe": "", + "list-unsubscribe-post": POST_HEADER, + }), + ).toBeNull(); + }); + + it("returns null when the Post header is missing", () => { + expect( + parseOneClickUnsubscribe({ + "list-unsubscribe": "", + }), + ).toBeNull(); + }); + + it("returns null when the Post header has the wrong value", () => { + expect( + parseOneClickUnsubscribe({ + "list-unsubscribe": "", + "list-unsubscribe-post": "List-Unsubscribe=Something", + }), + ).toBeNull(); + }); + + it("matches headers and Post value case-insensitively", () => { + expect( + parseOneClickUnsubscribe({ + "List-Unsubscribe": "", + "List-Unsubscribe-Post": "list-unsubscribe=ONE-CLICK", + }), + ).toBe("https://example.com/u/1"); + }); + + it("ignores plaintext http URLs", () => { + expect( + parseOneClickUnsubscribe({ + "list-unsubscribe": "", + "list-unsubscribe-post": POST_HEADER, + }), + ).toBeNull(); + }); + + it("returns null when there are no headers", () => { + expect(parseOneClickUnsubscribe({})).toBeNull(); + }); +}); + +describe("sendOneClickUnsubscribe", () => { + it("POSTs the one-click body and returns true on success", async () => { + let captured: { method: string; contentType: string; body: string } | null = + null; + server.use( + http.post("https://example.com/u/1", async ({ request }) => { + captured = { + method: request.method, + contentType: request.headers.get("content-type") ?? "", + body: await request.text(), + }; + return HttpResponse.text("ok"); + }), + ); + + const ok = await sendOneClickUnsubscribe("https://example.com/u/1"); + + expect(ok).toBe(true); + expect(captured).toEqual({ + method: "POST", + contentType: "application/x-www-form-urlencoded", + body: POST_HEADER, + }); + }); + + it("returns false on a non-ok response", async () => { + server.use( + http.post("https://example.com/u/1", () => + HttpResponse.text("nope", { status: 404 }), + ), + ); + expect(await sendOneClickUnsubscribe("https://example.com/u/1")).toBe( + false, + ); + }); + + it("returns false (no throw) on a network error", async () => { + server.use( + http.post("https://example.com/u/1", () => HttpResponse.error()), + ); + expect(await sendOneClickUnsubscribe("https://example.com/u/1")).toBe( + false, + ); + }); +}); + +describe("sendUnsubscribes", () => { + it("de-dupes URLs and bumps unsubscribes_sent by the success count", async () => { + const env = createMockEnv() as unknown as Env; + let hitsOne = 0; + let hitsTwo = 0; + server.use( + http.post("https://example.com/a", () => { + hitsOne += 1; + return HttpResponse.text("ok"); + }), + http.post("https://example.com/b", () => { + hitsTwo += 1; + return HttpResponse.text("ok"); + }), + ); + + await sendUnsubscribes( + [ + "https://example.com/a", + "https://example.com/a", + "https://example.com/b", + ], + env, + ); + + expect(hitsOne).toBe(1); + expect(hitsTwo).toBe(1); + const counters = await getCounters(env.EMAIL_STORAGE); + expect(counters.unsubscribes_sent).toBe(2); + }); + + it("only counts successful requests", async () => { + const env = createMockEnv() as unknown as Env; + server.use( + http.post("https://example.com/ok", () => HttpResponse.text("ok")), + http.post("https://example.com/bad", () => + HttpResponse.text("no", { status: 500 }), + ), + ); + + await sendUnsubscribes( + ["https://example.com/ok", "https://example.com/bad"], + env, + ); + + const counters = await getCounters(env.EMAIL_STORAGE); + expect(counters.unsubscribes_sent).toBe(1); + }); + + it("does nothing for an empty list", async () => { + const env = createMockEnv() as unknown as Env; + await sendUnsubscribes([], env); + const counters = await getCounters(env.EMAIL_STORAGE); + expect(counters.unsubscribes_sent).toBe(0); + }); +}); diff --git a/src/utils/unsubscribe.ts b/src/utils/unsubscribe.ts new file mode 100644 index 0000000..7a6ed05 --- /dev/null +++ b/src/utils/unsubscribe.ts @@ -0,0 +1,83 @@ +import { Env } from "../types"; +import { UNSUBSCRIBE_TIMEOUT_MS } from "../config/constants"; +import { bumpCounters } from "./stats"; +import { logger } from "../lib/logger"; + +/** + * Extract a one-click unsubscribe URL from a stored email's headers per + * RFC 8058. Returns the first `https:` URL in `List-Unsubscribe` only when + * `List-Unsubscribe-Post: List-Unsubscribe=One-Click` is also present — that + * Post header is what authorises an unattended one-click POST. `mailto:` and + * plaintext `http:` links are ignored (Workers cannot send SMTP and we never + * unsubscribe over plaintext). Header keys are matched case-insensitively; + * `EmailData.headers` already lowercases them, but we don't rely on it. + */ +export function parseOneClickUnsubscribe( + headers: Record, +): string | null { + let listUnsubscribe = ""; + let post = ""; + for (const [key, value] of Object.entries(headers)) { + const k = key.toLowerCase(); + if (k === "list-unsubscribe") listUnsubscribe = value; + else if (k === "list-unsubscribe-post") post = value; + } + + if (post.trim().toLowerCase() !== "list-unsubscribe=one-click") return null; + + const matches = listUnsubscribe.match(/<([^>]+)>/g); + if (!matches) return null; + for (const token of matches) { + const url = token.slice(1, -1).trim(); + if (/^https:\/\//i.test(url)) return url; + } + return null; +} + +/** + * Fire a single RFC 8058 one-click unsubscribe POST. Returns whether the + * endpoint accepted it. Never throws: network/timeout errors are logged and + * reported as a failure so callers can keep going. + */ +export async function sendOneClickUnsubscribe(url: string): Promise { + try { + const res = await fetch(url, { + method: "POST", + redirect: "follow", + signal: AbortSignal.timeout(UNSUBSCRIBE_TIMEOUT_MS), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "kill-the-news/1.0", + }, + body: "List-Unsubscribe=One-Click", + }); + return res.ok; + } catch (error) { + logger.warn("One-click unsubscribe failed", { url, error: String(error) }); + return false; + } +} + +/** + * Send one-click unsubscribe requests for a batch of URLs (de-duplicated) and + * record the number that succeeded in the `unsubscribes_sent` counter. Never + * throws — intended to run in the background via ctx.waitUntil on feed deletion. + */ +export async function sendUnsubscribes( + urls: string[], + env: Env, +): Promise { + const unique = Array.from(new Set(urls.filter(Boolean))); + if (unique.length === 0) return; + + const results = await Promise.allSettled( + unique.map((url) => sendOneClickUnsubscribe(url)), + ); + const succeeded = results.filter( + (r) => r.status === "fulfilled" && r.value, + ).length; + + if (succeeded > 0) { + await bumpCounters(env.EMAIL_STORAGE, { unsubscribes_sent: succeeded }); + } +}