diff --git a/backend/api/package.json b/backend/api/package.json index 127b6f5e..c2d3270f 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -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", diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index dd2deede..cbdd9420 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.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} = { 'update-event': updateEvent, health: health, stats: stats, + 'unsubscribe/:token': unsubscribe, me: getMe, 'get-user-and-profile': getUserAndProfileHandler, report: report, diff --git a/backend/api/src/unsubscribe.ts b/backend/api/src/unsubscribe.ts new file mode 100644 index 00000000..dbb57998 --- /dev/null +++ b/backend/api/src/unsubscribe.ts @@ -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} +} diff --git a/backend/email/emails/functions/helpers.tsx b/backend/email/emails/functions/helpers.tsx index d2475e9a..4525f616 100644 --- a/backend/email/emails/functions/helpers.tsx +++ b/backend/email/emails/functions/helpers.tsx @@ -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': `, <${unsubscribeUrlOneClick}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'List-ID': 'Compass ', + }, }) } diff --git a/backend/email/emails/functions/send-email.ts b/backend/email/emails/functions/send-email.ts index 4e8fef44..13436c5c 100644 --- a/backend/email/emails/functions/send-email.ts +++ b/backend/email/emails/functions/send-email.ts @@ -25,10 +25,6 @@ export const sendEmail = async ( const {data, error} = await resend.emails.send( { replyTo: 'Compass ', - headers: { - 'List-Unsubscribe': ', <${UNSUBSCRIBE_URL}>', - 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', - }, ...payload, }, options, diff --git a/backend/shared/src/unsubscribe-tokens.ts b/backend/shared/src/unsubscribe-tokens.ts new file mode 100644 index 00000000..07e20622 --- /dev/null +++ b/backend/shared/src/unsubscribe-tokens.ts @@ -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 { + 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}` +} diff --git a/backend/supabase/email_unsubscribe_tokens.sql b/backend/supabase/email_unsubscribe_tokens.sql new file mode 100644 index 00000000..2f48d0e6 --- /dev/null +++ b/backend/supabase/email_unsubscribe_tokens.sql @@ -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); diff --git a/backend/supabase/migration.sql b/backend/supabase/migration.sql index 38dd1f49..76755842 100644 --- a/backend/supabase/migration.sql +++ b/backend/supabase/migration.sql @@ -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; diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 338daa5e..7a407ed5 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -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,