Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions config/sourceful.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@
"replyTo": "support@sourceful.energy",
"opens": false,
"fname": "there"
},
"app-users": {
"name": "Sourceful app users",
"from": "Sourceful Energy <the-source@news.sourceful.energy>",
"replyTo": "info@sourceful.energy",
"opens": false,
"fname": "there",
"consent": "app-account-2026-11"
}
},
"admins": [
Expand Down Expand Up @@ -71,6 +79,12 @@
"https://www.sourceful.energy"
],
"key": true
},
"sourceful-app": {
"lists": [
"app-users"
],
"key": true
}
}
}
10 changes: 10 additions & 0 deletions docs/signup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -43,6 +43,8 @@ export async function handle(request: Request, env: Env): Promise<Response> {
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);
Expand Down
33 changes: 32 additions & 1 deletion src/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -44,6 +44,37 @@ export async function signupRoute(request: Request, env: Env): Promise<Response>
}
}

// 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<Response> {
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<string, FormConfig>, 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);
Expand Down
54 changes: 54 additions & 0 deletions test/signup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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<string, unknown>) => r.first_name), [null]);
});

function unsubscribe(body: Record<string, string>, o: Options = {}) {
const headers: Record<string, string> = { '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/);
});
6 changes: 4 additions & 2 deletions test/webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@ function sesEvent(event: Record<string, unknown>) {

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: '<p>x</p><a href="*|UNSUB|*">u</a>' }), env);
assert.equal((await next.json() as Record<string, number>).recipients, 1);
});
Expand Down
Loading