From 5d1ed7e0756828b7db2fce5e221626f0ea1b9bab Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 4 Nov 2026 10:24:25 +0100 Subject: [PATCH] Let a server form take readers off a list; give app sign-ups their own list - POST /unsubscribe: a form with a key takes an address off one of its lists and suppresses it there, so the app's newsletter toggle works. Same answer whether or not the address was on the list; never from a browser. - config/sourceful.json: list app-users and form sourceful-app, so accounts the app signs up stay apart from The Source until someone decides. - The bounce webhook test looked for msg-2, but a batch sends in parallel; it now looks up the recipient's message id. Co-Authored-By: Claude Opus 5.5 --- config/sourceful.json | 14 +++++++++++ docs/signup.md | 10 ++++++++ src/app.ts | 4 +++- src/signup.ts | 33 +++++++++++++++++++++++++- test/signup.test.ts | 54 +++++++++++++++++++++++++++++++++++++++++++ test/webhook.test.ts | 6 +++-- 6 files changed, 117 insertions(+), 4 deletions(-) diff --git a/config/sourceful.json b/config/sourceful.json index 7e92533..0fb8e2f 100644 --- a/config/sourceful.json +++ b/config/sourceful.json @@ -36,6 +36,14 @@ "replyTo": "support@sourceful.energy", "opens": false, "fname": "there" + }, + "app-users": { + "name": "Sourceful app users", + "from": "Sourceful Energy ", + "replyTo": "info@sourceful.energy", + "opens": false, + "fname": "there", + "consent": "app-account-2026-11" } }, "admins": [ @@ -71,6 +79,12 @@ "https://www.sourceful.energy" ], "key": true + }, + "sourceful-app": { + "lists": [ + "app-users" + ], + "key": true } } } diff --git a/docs/signup.md b/docs/signup.md index 6b42447..687d16e 100644 --- a/docs/signup.md +++ b/docs/signup.md @@ -141,6 +141,16 @@ curl https://news.example.com/subscribe \ Keep the route's own checks and its own limit per visitor. Utskick's rate limit sees the server's address, not the visitor's, so for a server it caps the server as a whole: 10 sign-ups a minute. Pass the visitor's address in `X-Client-IP` and the limit counts per visitor instead; the form's key vouches for the header, so Utskick reads it only from a form with a key. +A server form can also take someone off one of its lists, as when a reader turns the newsletter off in an app: + +```bash +curl https://news.example.com/unsubscribe \ + -H "Authorization: Bearer $FORM_KEY" -H 'Content-Type: application/json' \ + -d '{"email": "reader@example.com", "list": "newsletter"}' +``` + +It answers `200 {"ok": true}` whether or not the address was on the list, and the same errors as `/subscribe`. The address is suppressed for that list, so the form cannot sign it up again; the reader joins again by writing to you. Only a form with a key can do this, never a browser form. Utskick has no call that says whether an address is on a list. + ## 3. A Cloudflare Worker or Pages project, over a service binding Bind the `utskick` Worker as a service (in a Pages project: Settings → Bindings → Service binding) and call the method: diff --git a/src/app.ts b/src/app.ts index 1bf4b73..3299fa0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,7 +12,7 @@ import { archiveUnsubscribePage, confirmPage, donePage, notFound, testDonePage } import { render } from './render.ts'; import { createSend, retryFailed, retryUnknown, runBatch, stats } from './send.ts'; import { isSnsUrl, regionOf, verifySns, type SnsMessage } from './sns.ts'; -import { boundedText, signupRoute } from './signup.ts'; +import { boundedText, signupRoute, unsubscribeRoute } from './signup.ts'; import { burstLinks, burstMs, machineClick, machineOpen } from './tracking.ts'; import { withLists } from './lists.ts'; import { HttpError, iso, json, normalizeEmail, sameSecret, sha256 } from './util.ts'; @@ -43,6 +43,8 @@ export async function handle(request: Request, env: Env): Promise { if (res) return res; } if (url.pathname === '/subscribe') return await signupRoute(request, env); + // GET /unsubscribe is the archive's page for readers; a POST is a server form taking someone off. + if (url.pathname === '/unsubscribe' && request.method === 'POST') return await unsubscribeRoute(request, env); const approval = url.pathname.match(/^\/approve\/([A-Za-z0-9_-]{43})$/); if (approval) return await approvalRoute(request, env, approval[1]); if (url.pathname === '/dashboard' || url.pathname.startsWith('/dashboard/')) return await dashboardRoute(request, env, url); diff --git a/src/signup.ts b/src/signup.ts index f77e51f..51f1d42 100644 --- a/src/signup.ts +++ b/src/signup.ts @@ -2,7 +2,7 @@ // Origin, a server route by its key. The answer is the same for a new, a known and a suppressed // address, so the form reveals nothing, and a refused request stores nothing. import { isList, type Config, type FormConfig } from './config.ts'; -import { firstName, subscribe, type Signup } from './contacts.ts'; +import { firstName, forget, subscribe, type Signup } from './contacts.ts'; import type { Env } from './env.ts'; import { HttpError, json, normalizeEmail, sameSecret } from './util.ts'; @@ -44,6 +44,37 @@ export async function signupRoute(request: Request, env: Env): Promise } } +// POST /unsubscribe: a server form takes an address off one of its lists, as when a reader turns the +// newsletter off in an app. Only with the form's key: in a browser, anyone could take anyone off. +// The address is suppressed for that list, so the form cannot sign it up again. The answer is the +// same whether or not it was on the list. +export async function unsubscribeRoute(request: Request, env: Env): Promise { + if (request.method !== 'POST') { + const res = json({ error: 'method_not_allowed' }, 405); + res.headers.set('Allow', 'POST'); + return res; + } + try { + const auth = request.headers.get('Authorization'); + if (auth === null) throw new HttpError(401, 'key_required'); + const form = await formByKey(env, env.CONFIG.forms ?? {}, auth); + if (!form) throw new HttpError(401, 'invalid_key'); + const origin = request.headers.get('Origin'); + if (origin !== null && !form.config.origins?.includes(origin)) throw new HttpError(403, 'origin_not_allowed'); + const fields = await readFields(request); + const client = clientKey(request.headers.get('X-Client-IP') || request.headers.get('CF-Connecting-IP') || 'unknown'); + if (!await signupAllowed(env, `${form.name}:${client}`)) throw new HttpError(429, 'rate_limited'); + const email = normalizeEmail(fields.email); + if (!email) throw new HttpError(400, 'invalid_email'); + if (typeof fields.list !== 'string' || !form.config.lists.includes(fields.list) || !isList(env.CONFIG, fields.list)) throw new HttpError(400, 'invalid_list'); + await forget(env, email, fields.list, 'unsubscribe'); + return json({ ok: true }); + } catch (error) { + if (!(error instanceof HttpError)) throw error; + return json({ error: error.message }, error.status); + } +} + // Only a browser form is posted from a page. A form with a key is posted by its server, which needs no CORS. function preflight(forms: Record, origin: string | null) { if (origin === null || !Object.values(forms).some(f => !f.key && f.origins?.includes(origin))) return cors(json({ error: 'origin_not_allowed' }, 403), null); diff --git a/test/signup.test.ts b/test/signup.test.ts index b69371b..e258249 100644 --- a/test/signup.test.ts +++ b/test/signup.test.ts @@ -275,6 +275,20 @@ test('Sourceful\'s config: the website form posts from its server with a key, fo assert.deepEqual((await stored(e)).map(r => [r.email, r.list, r.consent, r.source]), [['reader@example.com', 'the-source', 'the-source-2026-11', 'sourceful-web']]); }); +test('Sourceful\'s config: the app\'s accounts go to a list of their own, apart from The Source', async () => { + const e = env(); + e.CONFIG = JSON.parse(readFileSync(new URL('../config/sourceful.json', import.meta.url), 'utf8')); + const app = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + e.FORM_KEYS = JSON.stringify({ 'sourceful-web': key, 'sourceful-app': app }); + const body = { email: 'user@example.com', list: 'app-users', consent: 'app-account-2026-11' }; + await refused(await handle(post({ ...body, list: 'the-source', consent: 'the-source-2026-11' }, { origin: null, key: app }), e), 400, 'invalid_list'); + await refused(await handle(post(body, { origin: null, key }), e), 400, 'invalid_list'); + assert.equal((await handle(post(body, { origin: null, key: app }), e)).status, 200); + assert.deepEqual((await stored(e)).map(r => [r.list, r.consent, r.source]), [['app-users', 'app-account-2026-11', 'sourceful-app']]); + assert.equal((await handle(unsubscribe({ email: body.email, list: 'app-users' }, { key: app }), e)).status, 200); + assert.deepEqual(await stored(e), []); +}); + test('the rate limit counts an IPv6 client per /64 and an IPv4 client per address', async () => { const { clientKey } = await import('../src/signup.ts'); assert.equal(clientKey('203.0.113.7'), '203.0.113.7'); @@ -292,3 +306,43 @@ test('a first name that is not plainly a name is dropped, and the sign-up still const names = await e.DB.prepare('SELECT first_name FROM contacts').all(); assert.deepEqual(names.results.map((r: Record) => r.first_name), [null]); }); + +function unsubscribe(body: Record, o: Options = {}) { + const headers: Record = { 'Content-Type': 'application/json', 'CF-Connecting-IP': '192.0.2.1' }; + if (o.origin) headers.Origin = o.origin; + if (o.key) headers.Authorization = `Bearer ${o.key}`; + return new Request('https://news.test/unsubscribe', { method: o.method ?? 'POST', headers, body: o.method === 'GET' ? undefined : JSON.stringify(body) }); +} + +test('a server form takes an address off one of its lists with its key, and cannot sign it up again', async () => { + const e = env(); + const off = { email: 'Reader@Example.com', list: 'the-source' }; + await handle(post(signup, { origin: null, key }), e); + await handle(post({ ...signup, list: 'ftw-news', consent: 'news-2026-10-30' }, { origin: null, key }), e); + + await refused(await handle(unsubscribe(off), e), 401, 'key_required'); + await refused(await handle(unsubscribe(off, { key: key.slice(0, -1) + 'f' }), e), 401, 'invalid_key'); + await refused(await handle(unsubscribe(off, { key, origin: 'https://evil.example' }), e), 403, 'origin_not_allowed'); + await refused(await handle(unsubscribe({ ...off, list: 'ftw-box' }, { key }), e), 400, 'invalid_list'); + await refused(await handle(unsubscribe({ ...off, email: 'not an address' }, { key }), e), 400, 'invalid_email'); + assert.equal((await stored(e)).length, 2); + + const res = await handle(unsubscribe(off, { key }), e); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { ok: true }); + // Off The Source only; still on FTW news. + assert.deepEqual((await stored(e)).map(r => r.list), ['ftw-news']); + assert.deepEqual(await rows(e, 'SELECT scope, reason FROM suppressions'), [{ scope: 'the-source', reason: 'unsubscribe' }]); + // The same form cannot put the address back: someone who left writes in. + assert.equal((await handle(post(signup, { origin: null, key }), e)).status, 200); + assert.deepEqual((await stored(e)).map(r => r.list), ['ftw-news']); + // An address that was never on the list gets the same answer. + assert.deepEqual(await (await handle(unsubscribe({ ...off, email: 'stranger@example.com' }, { key }), e)).json(), { ok: true }); +}); + +test('a GET to /unsubscribe is still the archive\'s page for readers, never the server route', async () => { + const e = env(); + const res = await handle(unsubscribe({}, { method: 'GET', key }), e); + assert.equal(res.status, 200); + assert.match(res.headers.get('Content-Type') ?? '', /text\/html/); +}); diff --git a/test/webhook.test.ts b/test/webhook.test.ts index 0a7a7ad..9973006 100644 --- a/test/webhook.test.ts +++ b/test/webhook.test.ts @@ -31,15 +31,17 @@ function sesEvent(event: Record) { test('a permanent bounce suppresses the address on every list', async () => { const env = await sentTo(['ok@example.com', 'bounce@example.com']); + // A batch sends in parallel, so which recipient got msg-2 varies. + const [{ message_id: id }] = await rows(env, `SELECT d.message_id FROM deliveries d JOIN contacts c ON c.id = d.contact_id WHERE c.email = 'bounce@example.com'`); const res = await handle(post(sesEvent({ eventType: 'Bounce', - mail: { messageId: 'msg-2', destination: ['bounce@example.com'] }, + mail: { messageId: id, destination: ['bounce@example.com'] }, bounce: { bounceType: 'Permanent', bouncedRecipients: [{ emailAddress: 'bounce@example.com' }] }, })), env); assert.equal(res.status, 200); assert.deepEqual(await rows(env, 'SELECT scope, reason FROM suppressions'), [{ scope: '*', reason: 'bounce' }]); assert.deepEqual(await rows(env, `SELECT email FROM contacts ORDER BY email`), [{ email: 'ok@example.com' }]); - assert.deepEqual(await rows(env, `SELECT e.type, d.message_id FROM events e JOIN deliveries d ON d.id = e.delivery_id`), [{ type: 'bounce', message_id: 'msg-2' }]); + assert.deepEqual(await rows(env, `SELECT e.type, d.message_id FROM events e JOIN deliveries d ON d.id = e.delivery_id`), [{ type: 'bounce', message_id: id }]); const next = await handle(admin('/admin/sends', { key: 'the-source-23', list: 'the-source', kind: 'live', subject: 'S', html: '

x

u' }), env); assert.equal((await next.json() as Record).recipients, 1); });