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 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-23 14:35:05 +02:00
parent eb12f21894
commit 3ad0188bc0
14 changed files with 558 additions and 4 deletions
+1
View File
@@ -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
+1 -1
View File
@@ -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://<domain>/favicon.ico` or a parsed `<link rel="icon">`, 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 `<image>` / Atom `<icon>` 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
+8
View File
@@ -816,6 +816,14 @@
<p>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.</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
</div>
<h3>Auto-Unsubscribe on Delete</h3>
<p>Deleting a feed fires RFC 8058 one-click unsubscribe requests to its newsletters, so the messages stop arriving at the now-dead address.</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
+3
View File
@@ -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;
+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: ["news@example.com"],
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({
"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": "<https://one.com/u/1>",
"list-unsubscribe-post": "List-Unsubscribe=One-Click",
},
}),
env as any,
);
await processEmail(
makeInput({
senders: ["b@two.com"],
headers: {
"list-unsubscribe": "<https://two.com/u/1>",
"list-unsubscribe-post": "List-Unsubscribe=One-Click",
},
}),
env as any,
);
await processEmail(
makeInput({
senders: ["a@one.com"],
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({
"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": "<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();
});
});
+14
View File
@@ -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,
+116 -1
View File
@@ -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<unknown>[] = [];
const ctx = {
waitUntil: (p: Promise<unknown>) => 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<unknown>[] = [];
const ctx = {
waitUntil: (p: Promise<unknown>) => 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();
+28 -1
View File
@@ -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}`,
);
+23 -1
View File
@@ -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<string[]> {
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,
+1
View File
@@ -119,6 +119,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
tone="danger"
/>
<Stat label="Net feeds" value={netFeeds} />
<Stat label="Unsubscribes sent" value={stats.unsubscribes_sent} />
<Stat
label="Last feed created"
value={formatRelative(stats.last_feed_created_at)}
+4
View File
@@ -46,6 +46,9 @@ export interface FeedConfig {
export interface FeedMetadata {
emails: EmailMetadata[];
iconDomain?: string; // Most recent sender's domain, used to resolve the feed icon
// 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<string, string>;
}
// 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)
+2
View File
@@ -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<Counters> {
@@ -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;
+184
View File
@@ -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": "<https://news.example.com/u?t=abc>",
"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":
"<mailto:unsub@example.com>, <https://example.com/u/1>",
"list-unsubscribe-post": POST_HEADER,
}),
).toBe("https://example.com/u/1");
});
it("returns null for a mailto-only header", () => {
expect(
parseOneClickUnsubscribe({
"list-unsubscribe": "<mailto:unsub@example.com>",
"list-unsubscribe-post": POST_HEADER,
}),
).toBeNull();
});
it("returns null when the Post header is missing", () => {
expect(
parseOneClickUnsubscribe({
"list-unsubscribe": "<https://example.com/u/1>",
}),
).toBeNull();
});
it("returns null when the Post header has the wrong value", () => {
expect(
parseOneClickUnsubscribe({
"list-unsubscribe": "<https://example.com/u/1>",
"list-unsubscribe-post": "List-Unsubscribe=Something",
}),
).toBeNull();
});
it("matches headers and Post value case-insensitively", () => {
expect(
parseOneClickUnsubscribe({
"List-Unsubscribe": "<https://example.com/u/1>",
"List-Unsubscribe-Post": "list-unsubscribe=ONE-CLICK",
}),
).toBe("https://example.com/u/1");
});
it("ignores plaintext http URLs", () => {
expect(
parseOneClickUnsubscribe({
"list-unsubscribe": "<http://example.com/u/1>",
"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);
});
});
+83
View File
@@ -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, string>,
): 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<boolean> {
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<void> {
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 });
}
}