Add email unsubscribe token support: implement token-based unsubscription handling, create API endpoint for unsubscribing via tokens, and update List-Unsubscribe header for one-click functionality.

This commit is contained in:
MartinBraquet
2026-04-09 10:39:58 +02:00
parent f5e16f68de
commit ff6115f4a6
9 changed files with 159 additions and 6 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@compass/api",
"version": "1.34.1",
"version": "1.35.0",
"private": true,
"description": "Backend API endpoints",
"main": "src/serve.ts",

View File

@@ -89,6 +89,7 @@ import {setLastOnlineTime} from './set-last-online-time'
import {shipProfiles} from './ship-profiles'
import {starProfile} from './star-profile'
import {stats} from './stats'
import {unsubscribe} from './unsubscribe'
import {updateEvent} from './update-event'
import {updateMe} from './update-me'
import {updateNotifSettings} from './update-notif-setting'
@@ -655,6 +656,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'update-event': updateEvent,
health: health,
stats: stats,
'unsubscribe/:token': unsubscribe,
me: getMe,
'get-user-and-profile': getUserAndProfileHandler,
report: report,

View File

@@ -0,0 +1,73 @@
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIErrors, APIHandler} from './helpers/endpoint'
export const unsubscribe: APIHandler<'unsubscribe/:token'> = async ({token}, _auth) => {
// One-click check: if List-Unsubscribe header is present, it must be "One-Click"
// if (listUnsubscribe && listUnsubscribe !== 'One-Click') {
// throw APIErrors.badRequest('Invalid List-Unsubscribe value')
// }
const pg = createSupabaseDirectClient()
// Look up the token and get user info
const tokenRecord = await pg.oneOrNone<{
user_id: string
notification_type: string | null
used_at: Date | null
}>(
`SELECT user_id, notification_type, used_at
FROM email_unsubscribe_tokens
WHERE token = $1`,
[token],
)
if (!tokenRecord) {
throw APIErrors.notFound('Invalid or expired token')
}
if (tokenRecord.used_at) {
throw APIErrors.badRequest('Token already used')
}
// Mark token as used
await pg.none(
`UPDATE email_unsubscribe_tokens
SET used_at = now()
WHERE token = $1`,
[token],
)
// Update user's notification preferences to opt out of email
// If notification_type is specified, only unsubscribe from that type
// Otherwise, opt out of all email notifications
const {user_id, notification_type} = tokenRecord
if (notification_type) {
// Unsubscribe from specific notification type via email
await pg.none(
`UPDATE private_users
SET data = jsonb_set(
data,
'{notificationPreferences,${notification_type}}',
(data->'notificationPreferences'->'${notification_type}' || '[]'::jsonb) - 'email'
)
WHERE id = $1`,
[user_id],
)
} else {
// Unsubscribe from all emails by adding email to opt_out_all
await pg.none(
`UPDATE private_users
SET data = jsonb_set(
data,
'{notificationPreferences,opt_out_all}',
COALESCE(data->'notificationPreferences'->'opt_out_all', '[]'::jsonb) || '["email"]'::jsonb
)
WHERE id = $1`,
[user_id],
)
}
return {success: true}
}

View File

@@ -15,6 +15,7 @@ import React from 'react'
import {createT} from 'shared/locale'
import {getProfile} from 'shared/profiles/supabase'
import {getOptionsIdsToLabels} from 'shared/supabase/options'
import {createUnsubscribeToken, getUnsubscribeUrlOneClick} from 'shared/unsubscribe-tokens'
import {NewEndorsementEmail} from '../new-endorsement'
import {NewMessageEmail} from '../new-message'
@@ -197,9 +198,10 @@ export const sendNewEndorsementEmail = async (
}
export const sendShareCompassEmail = async (toUser: User, privateUser: PrivateUser) => {
const notificationType = 'platform_updates'
const {sendToEmail, unsubscribeUrl} = getNotificationDestinationsForUser(
privateUser,
'platform_updates',
notificationType,
)
const email = privateUser.email
if (!email || !sendToEmail) {
@@ -216,6 +218,9 @@ export const sendShareCompassEmail = async (toUser: User, privateUser: PrivateUs
"600 people in 6 months — here's how you help write what's next",
)
const token = await createUnsubscribeToken(toUser.id, notificationType)
const unsubscribeUrlOneClick = getUnsubscribeUrlOneClick(token)
return await sendEmail({
from: fromEmail,
subject,
@@ -228,6 +233,11 @@ export const sendShareCompassEmail = async (toUser: User, privateUser: PrivateUs
locale={locale}
/>,
),
headers: {
'List-Unsubscribe': `<mailto:unsubscribe@compassmeet.com?subject=${token}>, <${unsubscribeUrlOneClick}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
'List-ID': 'Compass <compassmeet.com>',
},
})
}

View File

@@ -25,10 +25,6 @@ export const sendEmail = async (
const {data, error} = await resend.emails.send(
{
replyTo: 'Compass <hello@compassmeet.com>',
headers: {
'List-Unsubscribe': '<mailto:unsubscribe@compassmeet.com>, <${UNSUBSCRIBE_URL}>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
...payload,
},
options,

View File

@@ -0,0 +1,38 @@
import {PROD_CONFIG} from 'common/envs/prod'
import {notification_preference} from 'common/user-notification-preferences'
import crypto from 'crypto'
import {createSupabaseDirectClient, SupabaseDirectClient} from './supabase/init'
/**
* Generate a cryptographically secure random token for email unsubscribe
*/
export function generateUnsubscribeToken(): string {
return crypto.randomBytes(32).toString('hex')
}
/**
* Create and store an unsubscribe token for a user
*/
export async function createUnsubscribeToken(
userId: string,
notificationType?: notification_preference,
): Promise<string> {
const token = generateUnsubscribeToken()
const pg: SupabaseDirectClient = createSupabaseDirectClient()
await pg.none(
`INSERT INTO email_unsubscribe_tokens (token, user_id, notification_type)
VALUES ($1, $2, $3)`,
[token, userId, notificationType],
)
return token
}
/**
* Get the unsubscribe URL for a token
*/
export function getUnsubscribeUrlOneClick(token: string): string {
return `https://${PROD_CONFIG.backendDomain}/unsubscribe?token=${token}`
}

View File

@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS email_unsubscribe_tokens
(
token TEXT NOT NULL,
user_id TEXT NOT NULL,
notification_type TEXT, -- NULL means unsubscribe from all
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
used_at TIMESTAMPTZ,
CONSTRAINT email_unsubscribe_tokens_pkey PRIMARY KEY (token)
);
-- Row Level Security
ALTER TABLE email_unsubscribe_tokens
ENABLE ROW LEVEL SECURITY;
-- Indexes
CREATE INDEX IF NOT EXISTS email_unsubscribe_tokens_user_id_idx
ON public.email_unsubscribe_tokens USING btree (user_id);
CREATE INDEX IF NOT EXISTS email_unsubscribe_tokens_created_at_idx
ON public.email_unsubscribe_tokens USING btree (created_at);

View File

@@ -55,4 +55,5 @@ BEGIN;
\i backend/supabase/migrations/20260308_add_importance_counts.sql
\i backend/supabase/migrations/20260319_add_compatibility_prompts_pinned.sql
\i backend/supabase/migrations/20260330_add_substance_fields_to_profiles.sql
\i backend/supabase/email_unsubscribe_tokens.sql
COMMIT;

View File

@@ -412,6 +412,18 @@ export const API = (_apiTypeCheck = {
summary: 'Update a notification preference for the user',
tag: 'Notifications',
},
'unsubscribe/:token': {
method: 'POST',
authed: false,
rateLimited: true,
props: z.object({
token: z.string(),
'List-Unsubscribe': z.string().optional(),
}),
returns: {} as {success: boolean},
summary: 'Unsubscribe from email notifications using a token',
tag: 'Notifications',
},
'update-user-locale': {
method: 'POST',
authed: true,