From 1f3aa8c050ff1988d4c4c0491208e96cb8f963c3 Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Fri, 2 Oct 2026 10:33:47 -0700 Subject: [PATCH] fix(functions): Report mailing list unsubscribe failures Leaving the mailing list confirms success only after Beehiiv accepts all linked subscriptions. Provider outages leave the action available so members can retry instead of seeing a false success message. Co-authored-by: Codex --- .../functions/account/mailing-list/index.ts | 4 +- apps/site/platform/account.ts | 16 ++-- apps/site/tests/platform-account.test.ts | 20 ++++- apps/site/tests/platform-unsubscribe.test.ts | 75 +++++++++++++++++++ 4 files changed, 106 insertions(+), 9 deletions(-) create mode 100644 apps/site/tests/platform-unsubscribe.test.ts diff --git a/apps/site/functions/account/mailing-list/index.ts b/apps/site/functions/account/mailing-list/index.ts index 34b8b5dc..cb7b281d 100644 --- a/apps/site/functions/account/mailing-list/index.ts +++ b/apps/site/functions/account/mailing-list/index.ts @@ -18,8 +18,8 @@ export const onRequestPost: PagesFunction = async ({ env, reques if (signed instanceof Response) return signed; const action = field(await formOf(request), 'action'); if (action === 'leave') { - await leaveMailingList(signed.env, signed.person.id); - return backToAccount('left'); + const outcome = await leaveMailingList(signed.env, signed.person.id); + return backToAccount(outcome); } if (action === 'rejoin') { const outcome = await rejoinMailingList(signed.env, signed.person); diff --git a/apps/site/platform/account.ts b/apps/site/platform/account.ts index 9997efef..d2555e4c 100644 --- a/apps/site/platform/account.ts +++ b/apps/site/platform/account.ts @@ -278,15 +278,18 @@ export async function unsubscribeEverywhere( env: AccountEnv, personId: string, fetcher: typeof fetch = fetch, -): Promise { +): Promise { + const ids = await beehiivIds(env.PLATFORM_DB, personId); + if (!ids.length) return true; const config = beehiiv(env); if (!config) { console.error('account: Beehiiv secrets are missing, so the unsubscribe is not sent'); - return; + return false; } - for (const id of await beehiivIds(env.PLATFORM_DB, personId)) { - await unsubscribe(config, id, fetcher); + for (const id of ids) { + if (!(await unsubscribe(config, id, fetcher))) return false; } + return true; } async function subscribeAddress( @@ -332,13 +335,14 @@ export async function leaveMailingList( env: AccountEnv, personId: string, fetcher: typeof fetch = fetch, -): Promise { +): Promise<'left' | 'unavailable'> { + if (!(await unsubscribeEverywhere(env, personId, fetcher))) return 'unavailable'; await new PersonService(env.PLATFORM_DB).withdrawConsent(personId, { scope: 'newsletter', source: 'account', withdrawnAt: nowIso(), }); - await unsubscribeEverywhere(env, personId, fetcher); + return 'left'; } export async function rejoinMailingList( diff --git a/apps/site/tests/platform-account.test.ts b/apps/site/tests/platform-account.test.ts index e8e7ddf3..19ef7e3a 100644 --- a/apps/site/tests/platform-account.test.ts +++ b/apps/site/tests/platform-account.test.ts @@ -185,7 +185,7 @@ void test('leaving makes a former member, and rejoining records a new consent', const db = memoryDb(); const person = await member(db); const { fetcher, calls } = fakeServices(); - await leaveMailingList(env(db), person.id, fetcher); + assert.equal(await leaveMailingList(env(db), person.id, fetcher), 'left'); assert.equal( (await new PersonService(db).getPerson(person.id))?.membership_status, 'former_member', @@ -203,6 +203,24 @@ void test('leaving makes a former member, and rejoining records a new consent', ); }); +void test('leaving remains retryable when the mailing provider fails', async () => { + for (const failure of ['rejected', 'network', 'missing_config']) { + const db = memoryDb(); + const person = await member(db); + const config = env(db); + if (failure === 'missing_config') delete config.LVBT_BEEHIIV_API_KEY; + const fetcher = (() => + failure === 'network' + ? Promise.reject(new Error('offline')) + : Promise.resolve(new Response(null, { status: 503 }))) as typeof fetch; + assert.equal(await leaveMailingList(config, person.id, fetcher), 'unavailable'); + assert.equal((await accountView(db, person.id))?.onMailingList, true); + assert.equal((await new PersonService(db).getPerson(person.id))?.membership_status, 'member'); + assert.equal(await leaveMailingList(env(db), person.id, fakeServices().fetcher), 'left'); + assert.equal((await accountView(db, person.id))?.onMailingList, false); + } +}); + void test('the download holds everything about this member and nothing about anyone else', async () => { const db = memoryDb(); const person = await member(db); diff --git a/apps/site/tests/platform-unsubscribe.test.ts b/apps/site/tests/platform-unsubscribe.test.ts new file mode 100644 index 00000000..060b311a --- /dev/null +++ b/apps/site/tests/platform-unsubscribe.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { onRequestPost } from '../functions/join/remove'; +import { signToken } from '../platform/core/signing'; +import { PersonService } from '../platform/storage/person-service'; +import { memoryDb } from './support/platform-db'; + +void test('one-click unsubscribe needs no session and updates the mailing list and consent', async (t) => { + const db = memoryDb(); + const people = new PersonService(db); + const { person } = await people.upsertFromSource({ + source: 'join_form', + fields: { email: 'one-click@example.org' }, + consent: { + scope: 'newsletter', + source: 'join_form', + method: 'checkbox', + wordingVersion: 'join-form-v1', + }, + }); + await people.linkIdentity(person.id, { + platform: 'beehiiv', + externalId: 'sub_one_click', + externalEmail: 'one-click@example.org', + linkMethod: 'created_by_platform', + }); + const token = await signToken('test-secret', { + purpose: 'remove_email', + subject: person.id, + expiresAt: Date.now() + 60_000, + }); + const invoke = () => + onRequestPost({ + env: { + PLATFORM_DB: db, + LVBT_LINK_SIGNING_SECRET: 'test-secret', + LVBT_BEEHIIV_API_KEY: 'test-api-key', + LVBT_BEEHIIV_PUBLICATION_ID: 'pub_test', + }, + request: new Request(`https://lasvegasfortransit.org/join/remove/?token=${token}`, { + method: 'POST', + body: new URLSearchParams({ 'List-Unsubscribe': 'One-Click' }), + }), + } as unknown as Parameters[0]); + + t.mock.method(globalThis, 'fetch', () => Promise.resolve(new Response(null, { status: 503 }))); + assert.equal((await invoke()).status, 503); + assert.equal((await people.getPerson(person.id))?.membership_status, 'member'); + assert.equal( + db.raw.prepare('SELECT withdrawn_at FROM consent_records').get()?.withdrawn_at, + null, + ); + + t.mock.method(globalThis, 'fetch', (_url: string, init: RequestInit) => { + assert.equal(init.method, 'PATCH'); + assert.ok(typeof init.body === 'string'); + assert.deepEqual(JSON.parse(init.body), { unsubscribe: true }); + return Promise.resolve(new Response(null, { status: 200 })); + }); + assert.equal((await invoke()).status, 200); + assert.ok(db.raw.prepare('SELECT withdrawn_at FROM consent_records').get()?.withdrawn_at); + assert.equal(await people.getPerson(person.id), null); + assert.equal(db.raw.prepare('SELECT email FROM people').get()?.email, null); +}); + +void test('one-click unsubscribe rejects forged links', async () => { + const response = await onRequestPost({ + env: { PLATFORM_DB: memoryDb(), LVBT_LINK_SIGNING_SECRET: 'test-secret' }, + request: new Request('https://lasvegasfortransit.org/join/remove/?token=forged.token', { + method: 'POST', + body: new URLSearchParams({ 'List-Unsubscribe': 'One-Click' }), + }), + } as unknown as Parameters[0]); + assert.equal(response.status, 400); +});