mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-11 01:59:14 -04:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
73
backend/api/src/unsubscribe.ts
Normal file
73
backend/api/src/unsubscribe.ts
Normal 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}
|
||||
}
|
||||
@@ -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>',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
38
backend/shared/src/unsubscribe-tokens.ts
Normal file
38
backend/shared/src/unsubscribe-tokens.ts
Normal 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}`
|
||||
}
|
||||
21
backend/supabase/email_unsubscribe_tokens.sql
Normal file
21
backend/supabase/email_unsubscribe_tokens.sql
Normal 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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user