diff --git a/api/prisma/migrations/62_unsubscribe_notification_link_translations/migration.sql b/api/prisma/migrations/62_unsubscribe_notification_link_translations/migration.sql new file mode 100644 index 00000000000..83691d1db9c --- /dev/null +++ b/api/prisma/migrations/62_unsubscribe_notification_link_translations/migration.sql @@ -0,0 +1,94 @@ +-- Replaces rentalOpportunity.footer.unsubscribeAndEmailSettings with two separate keys: +-- unsubscribeFromAll (one-click public unsubscribe link) +-- manageSubscriptions (sign-in link to manage notification preferences) +-- Only targets global translations (jurisdiction_id IS NULL). + +UPDATE translations +SET translations = jsonb_set( + translations, + '{rentalOpportunity,footer}', + COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "Unsubscribe from all", "manageSubscriptions": "Manage subscriptions"}'::jsonb +) +WHERE language = 'en' AND jurisdiction_id IS NULL; + +UPDATE translations +SET translations = jsonb_set( + translations, + '{rentalOpportunity,footer}', + COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "Cancelar todas las suscripciones", "manageSubscriptions": "Gestionar suscripciones"}'::jsonb +) +WHERE language = 'es' AND jurisdiction_id IS NULL; + +UPDATE translations +SET translations = jsonb_set( + translations, + '{rentalOpportunity,footer}', + COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "Hủy đăng ký tất cả", "manageSubscriptions": "Quản lý đăng ký"}'::jsonb +) +WHERE language = 'vi' AND jurisdiction_id IS NULL; + +UPDATE translations +SET translations = jsonb_set( + translations, + '{rentalOpportunity,footer}', + COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "取消所有订阅", "manageSubscriptions": "管理订阅"}'::jsonb +) +WHERE language = 'zh' AND jurisdiction_id IS NULL; + +UPDATE translations +SET translations = jsonb_set( + translations, + '{rentalOpportunity,footer}', + COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "I-unsubscribe sa lahat", "manageSubscriptions": "Pamahalaan ang mga subscription"}'::jsonb +) +WHERE language = 'tl' AND jurisdiction_id IS NULL; + +UPDATE translations +SET translations = jsonb_set( + translations, + '{rentalOpportunity,footer}', + COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "সমস্ত থেকে আনসাবস্ক্রাইব করুন", "manageSubscriptions": "সাবস্ক্রিপশন পরিচালনা করুন"}'::jsonb +) +WHERE language = 'bn' AND jurisdiction_id IS NULL; + +UPDATE translations +SET translations = jsonb_set( + translations, + '{rentalOpportunity,footer}', + COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "إلغاء الاشتراك من الكل", "manageSubscriptions": "إدارة الاشتراكات"}'::jsonb +) +WHERE language = 'ar' AND jurisdiction_id IS NULL; + +UPDATE translations +SET translations = jsonb_set( + translations, + '{rentalOpportunity,footer}', + COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "모든 구독 취소", "manageSubscriptions": "구독 관리"}'::jsonb +) +WHERE language = 'ko' AND jurisdiction_id IS NULL; + +UPDATE translations +SET translations = jsonb_set( + translations, + '{rentalOpportunity,footer}', + COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "Դադարեցնել բոլոր բաժանորդագրությունները", "manageSubscriptions": "Կառավարել բաժանորդագրությունները"}'::jsonb +) +WHERE language = 'hy' AND jurisdiction_id IS NULL; + +UPDATE translations +SET translations = jsonb_set( + translations, + '{rentalOpportunity,footer}', + COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) || '{"unsubscribeFromAll": "لغو اشتراک از همه", "manageSubscriptions": "مدیریت اشتراک‌ها"}'::jsonb +) +WHERE language = 'fa' AND jurisdiction_id IS NULL; + +-- Remove the old combined key from all languages +UPDATE translations +SET translations = jsonb_set( + translations, + '{rentalOpportunity,footer}', + (COALESCE(translations->'rentalOpportunity'->'footer', '{}'::jsonb) - 'unsubscribeAndEmailSettings') +) +WHERE jurisdiction_id IS NULL + AND translations->'rentalOpportunity'->'footer' ? 'unsubscribeAndEmailSettings'; diff --git a/api/prisma/seed-helpers/translation-factory.ts b/api/prisma/seed-helpers/translation-factory.ts index b1ad6e65ec7..590fdacdedb 100644 --- a/api/prisma/seed-helpers/translation-factory.ts +++ b/api/prisma/seed-helpers/translation-factory.ts @@ -226,8 +226,8 @@ const translations = ( }, footer: { accessibleMarketingFlyer: 'Volante de marketing accesible', - unsubscribeAndEmailSettings: - 'Cancelar suscripción y gestionar configuración de correo electrónico', + unsubscribeFromAll: 'Cancelar todas las suscripciones', + manageSubscriptions: 'Gestionar suscripciones', }, }, }; @@ -325,7 +325,8 @@ const translations = ( }, footer: { accessibleMarketingFlyer: 'Tờ rơi tiếp thị có thể truy cập', - unsubscribeAndEmailSettings: 'Hủy đăng ký và quản lý cài đặt email', + unsubscribeFromAll: 'Hủy đăng ký tất cả', + manageSubscriptions: 'Quản lý đăng ký', }, }, }; @@ -420,7 +421,8 @@ const translations = ( }, footer: { accessibleMarketingFlyer: '无障碍营销传单', - unsubscribeAndEmailSettings: '取消订阅并管理电子邮件设置', + unsubscribeFromAll: '取消所有订阅', + manageSubscriptions: '管理订阅', }, }, }; @@ -519,8 +521,8 @@ const translations = ( }, footer: { accessibleMarketingFlyer: 'Naa-access na flyer sa marketing', - unsubscribeAndEmailSettings: - 'Mag-unsubscribe at pamahalaan ang mga settings ng email', + unsubscribeFromAll: 'I-unsubscribe sa lahat', + manageSubscriptions: 'Pamahalaan ang mga subscription', }, }, }; @@ -618,8 +620,8 @@ const translations = ( }, footer: { accessibleMarketingFlyer: 'অ্যাক্সেসযোগ্য মার্কেটিং ফ্লায়ার', - unsubscribeAndEmailSettings: - 'আনসাবস্ক্রাইব করুন এবং ইমেইল সেটিংস পরিচালনা করুন', + unsubscribeFromAll: 'সমস্ত থেকে আনসাবস্ক্রাইব করুন', + manageSubscriptions: 'সাবস্ক্রিপশন পরিচালনা করুন', }, }, }; @@ -714,8 +716,8 @@ const translations = ( }, footer: { accessibleMarketingFlyer: 'نشرة تسويقية ميسّرة', - unsubscribeAndEmailSettings: - 'إلغاء الاشتراك وإدارة إعدادات البريد الإلكتروني', + unsubscribeFromAll: 'إلغاء الاشتراك من الكل', + manageSubscriptions: 'إدارة الاشتراكات', }, }, }; @@ -811,7 +813,8 @@ const translations = ( }, footer: { accessibleMarketingFlyer: '접근 가능한 마케팅 전단지', - unsubscribeAndEmailSettings: '구독 취소 및 이메일 설정 관리', + unsubscribeFromAll: '모든 구독 취소', + manageSubscriptions: '구독 관리', }, }, }; @@ -909,8 +912,8 @@ const translations = ( }, footer: { accessibleMarketingFlyer: 'Հասանելի մարքեթինգային թռուցիկ', - unsubscribeAndEmailSettings: - 'Դադարեցնել բաժանորդագրությունը և կառավարել էլ. փոստի կարգավորումները', + unsubscribeFromAll: 'Դադարեցնել բոլոր բաժանորդագրությունները', + manageSubscriptions: 'Կառավարել բաժանորդագրությունները', }, }, }; @@ -996,7 +999,8 @@ const translations = ( }, footer: { accessibleMarketingFlyer: 'بروشور بازاریابی قابل دسترس', - unsubscribeAndEmailSettings: 'لغو اشتراک و مدیریت تنظیمات ایمیل', + unsubscribeFromAll: 'لغو اشتراک از همه', + manageSubscriptions: 'مدیریت اشتراک‌ها', }, }, }; @@ -1356,8 +1360,8 @@ const translations = ( }, footer: { accessibleMarketingFlyer: 'Accessible marketing flyer', - unsubscribeAndEmailSettings: - 'Unsubscribe and manage email settings', + unsubscribeFromAll: 'Unsubscribe from all', + manageSubscriptions: 'Manage subscriptions', }, }, advocateApproved: { diff --git a/api/src/controllers/user.controller.ts b/api/src/controllers/user.controller.ts index 671e977f3bf..d6a65f2e493 100644 --- a/api/src/controllers/user.controller.ts +++ b/api/src/controllers/user.controller.ts @@ -62,6 +62,7 @@ import { AdvocateUserUpdate } from '../dtos/users/advocate-user-update.dto'; import { AdvocateUserCsvExporterService } from '../services/advocate-user-csv-export.service'; import { AdvocateUserAccept } from '../dtos/users/advocate-user-accept.dto'; import { UserNotificationPreferences } from '../dtos/users/user-notification-preferences.dto'; +import { UnsubscribeAllDto } from '../dtos/users/unsubscribe-all.dto'; @Controller('user') @ApiTags('user') @@ -414,6 +415,18 @@ export class UserController { return await this.userService.update(dto, req); } + @Put('/unsubscribe-from-all') + @ApiOperation({ + summary: 'Unsubscribe from all notifications', + operationId: 'unsubscribeFromAll', + }) + @ApiOkResponse({ type: SuccessDTO }) + async unsubscribeFromAll( + @Body() dto: UnsubscribeAllDto, + ): Promise { + return await this.userService.unsubscribeAll(dto); + } + @Put('/preferences') @ApiOperation({ summary: 'Update user notification preferences', diff --git a/api/src/dtos/users/unsubscribe-all.dto.ts b/api/src/dtos/users/unsubscribe-all.dto.ts new file mode 100644 index 00000000000..11cdabce79e --- /dev/null +++ b/api/src/dtos/users/unsubscribe-all.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsNotEmpty, IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class UnsubscribeAllDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsNotEmpty({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + email: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsNotEmpty({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + sig: string; +} diff --git a/api/src/services/aws-ses.service.ts b/api/src/services/aws-ses.service.ts index f64df988f45..f8442bcfc21 100644 --- a/api/src/services/aws-ses.service.ts +++ b/api/src/services/aws-ses.service.ts @@ -16,7 +16,10 @@ import { export class AwsSesService extends EmailProvider { sesClient: SESv2Client; - constructor(private readonly configService: ConfigService) { + // SES bulk send limit per API call. + static readonly MAX_EMAIL_TO_SEND_IN_BULK = 50; + + constructor(configService: ConfigService) { super(); const config: SESv2ClientConfig = { maxAttempts: configService.get('EMAIL_MAX_RETRIES', 3), @@ -41,24 +44,59 @@ export class AwsSesService extends EmailProvider { } if (isMultipleRecipients) { - const MAX_EMAIL_TO_SEND_IN_BULK = 50; + // SES inline templates (TemplateContent) do not support per-recipient variable + // substitution via ReplacementTemplateData — that only works with stored named + // templates. When perRecipientData is present, substitute __variable__ placeholders + // manually per user and send in concurrent batches of MAX_EMAIL_TO_SEND_IN_BULK. + if (input.perRecipientData?.length) { + for ( + let i = 0; + i < to.length; + i += AwsSesService.MAX_EMAIL_TO_SEND_IN_BULK + ) { + const batch = to.slice( + i, + i + AwsSesService.MAX_EMAIL_TO_SEND_IN_BULK, + ); + await Promise.all( + batch.map((email, j) => { + const data = input.perRecipientData[i + j]; + const resolvedBody = data + ? Object.entries(data).reduce( + (html, [key, value]) => html.replaceAll(`-${key}-`, value), + body, + ) + : body; + return this.send({ + ...input, + to: email, + body: resolvedBody, + perRecipientData: undefined, + }); + }), + ); + } + return; + } - const emailsChunked = to.reduce((accum, curr, i) => { - const chunk = Math.floor(i / MAX_EMAIL_TO_SEND_IN_BULK); - accum[chunk] = [].concat(accum[chunk] || [], curr); - return accum; - }, []); - for (const emailList of emailsChunked) { + const chunked: string[][] = []; + for ( + let i = 0; + i < to.length; + i += AwsSesService.MAX_EMAIL_TO_SEND_IN_BULK + ) { + chunked.push(to.slice(i, i + AwsSesService.MAX_EMAIL_TO_SEND_IN_BULK)); + } + + for (const chunk of chunked) { try { const commandInput: SendBulkEmailRequest = { FromEmailAddress: from, - BulkEmailEntries: emailList.map((email) => { - return { - Destination: { - ToAddresses: [email], - }, - }; - }), + BulkEmailEntries: chunk.map((email) => ({ + Destination: { + ToAddresses: [email], + }, + })), DefaultContent: { Template: { TemplateContent: { @@ -83,7 +121,7 @@ export class AwsSesService extends EmailProvider { } } catch (e) { console.log(e); - console.error(`Failed to send emails to ${emailList.toString()}`); + console.error(`Failed to send emails to ${chunk.toString()}`); } } } else { diff --git a/api/src/services/email-provider.service.ts b/api/src/services/email-provider.service.ts index 819614948fa..f96acd2714f 100644 --- a/api/src/services/email-provider.service.ts +++ b/api/src/services/email-provider.service.ts @@ -10,6 +10,15 @@ export type SendEmailInput = { subject: string; body: string; attachment?: EmailAttachmentData; + // Per-recipient template substitution data, parallel to `to[]` when sending to multiple recipients. + // Allows a bulk send to carry per-user values (e.g. a signed unsubscribe URL) without one API + // call per recipient. Each key maps to a \{{key}} escape in the Handlebars template, which outputs + // the literal {{key}} placeholder into the rendered HTML for providers to substitute at send time. + // SES: string-replaces {{key}} placeholders per user, sends 50 concurrent individual calls + // (inline TemplateContent doesn't support ReplacementTemplateData bulk substitution). + // SendGrid: uses personalizations with bare key substitutions — the SDK wraps keys in {{}} via + // wrapSubstitutions — up to 1000 recipients per API call. + perRecipientData?: Record[]; }; export abstract class EmailProvider { diff --git a/api/src/services/email.service.ts b/api/src/services/email.service.ts index 80fa9894340..1bf8e12f746 100644 --- a/api/src/services/email.service.ts +++ b/api/src/services/email.service.ts @@ -1,4 +1,5 @@ import { HttpException, Inject, Injectable, Logger } from '@nestjs/common'; +import { createHmac } from 'crypto'; import fs from 'fs'; import Handlebars from 'handlebars'; import Polyglot from 'node-polyglot'; @@ -120,6 +121,7 @@ export class EmailService { subject: string, body: string, attachment?: EmailAttachmentData, + perRecipientData?: Record[], ) { const isMultipleRecipients = Array.isArray(to); if (isMultipleRecipients && to.length === 0) { @@ -135,6 +137,7 @@ export class EmailService { subject, body, attachment, + perRecipientData, }; await this.emailProvider.send(emailParams); } @@ -1223,6 +1226,17 @@ export class EmailService { return listingDetails; } + // These links do not expire — a valid link from any historical email will always work. + // This is intentional so users can unsubscribe from old emails without needing a fresh link. + private generateUnsubscribeUrl(publicUrl: string, email: string): string { + const sig = createHmac('sha256', process.env.APP_SECRET) + .update(email) + .digest('hex'); + return `${publicUrl}/unsubscribe?email=${encodeURIComponent( + email, + )}&sig=${sig}`; + } + public async listingPublishNotification( jurisdictionId: IdDTO, listing: Listing, @@ -1258,19 +1272,32 @@ export class EmailService { url: `${jurisdiction.publicUrl}/${code}/listing/${listing.id}/${listing.urlSlug}`, })); + const subject = this.polyglot.t(`rentalOpportunity.subject`, { + listingName: listing.name, + }); + const manageSubscriptionsUrl = `${jurisdiction.publicUrl}/sign-in?redirectUrl=/account/notifications`; + + const perRecipientData = emails[language].map((email) => ({ + unsubscribeUrl: this.generateUnsubscribeUrl( + jurisdiction.publicUrl, + email, + ), + })); + const templateBody = this.template('listing-opportunity')({ + listingName: listing.name, + tableRows: listingDetails, + languageUrls: emailButtons, + accessibleMarketingFlyerUrl: listing.accessibleMarketingFlyer, + unsubscribeUrl: '-unsubscribeUrl-', + manageSubscriptionsUrl, + }); await this.send( emails[language], jurisdiction.emailFromAddress, - this.polyglot.t(`rentalOpportunity.subject`, { - listingName: listing.name, - }), - this.template('listing-opportunity')({ - listingName: listing.name, - tableRows: listingDetails, - languageUrls: emailButtons, - accessibleMarketingFlyerUrl: listing.accessibleMarketingFlyer, - emailSettingsUrl: `${jurisdiction.publicUrl}/sign-in?redirectUrl=/account/notifications`, - }), + subject, + templateBody, + undefined, + perRecipientData, ); } } catch (err) { diff --git a/api/src/services/sendgrid.service.ts b/api/src/services/sendgrid.service.ts index a1ec63fe0aa..5f070a44fe2 100644 --- a/api/src/services/sendgrid.service.ts +++ b/api/src/services/sendgrid.service.ts @@ -9,6 +9,9 @@ export class SendGridService extends EmailProvider { maxRetries: number; mailService: MailService; + // SendGrid allows up to 1000 personalizations per API call (platform limit). + static readonly MAX_PERSONALIZATIONS = 1000; + constructor(private readonly configService: ConfigService) { super(); this.mailService = new MailService(); @@ -17,6 +20,43 @@ export class SendGridService extends EmailProvider { } public async send(input: SendEmailInput): Promise { + // When per-recipient data is present, use SendGrid personalizations so all + // recipients are handled in one API call per 1000 (rather than one call each) which is SendGrid's personalization limit. + // Substitution keys are passed bare (e.g. 'unsubscribeUrl') — the @sendgrid/mail SDK wraps + // them in {{}} automatically via wrapSubstitutions, so they match \{{key}} placeholders in the body. + if (Array.isArray(input.to) && input.perRecipientData?.length) { + const chunks: string[][] = []; + for ( + let i = 0; + i < input.to.length; + i += SendGridService.MAX_PERSONALIZATIONS + ) { + chunks.push( + input.to.slice(i, i + SendGridService.MAX_PERSONALIZATIONS), + ); + } + + return Promise.all( + chunks.map((chunk, chunkIndex) => { + const offset = chunkIndex * SendGridService.MAX_PERSONALIZATIONS; + const emailParams: MailDataRequired = { + from: input.from, + subject: input.subject, + html: input.body, + // Wrappers tell the SDK to emit '-key-' substitution tags rather than the + // default '{{key}}', which SendGrid would interpret as a dynamic template + // expression and silently replace with empty string. + substitutionWrappers: ['-', '-'], + personalizations: chunk.map((email, i) => ({ + to: [{ email }], + substitutions: input.perRecipientData[offset + i] || {}, + })), + }; + return this.send_inner_params(emailParams, this.maxRetries); + }), + ); + } + return this.send_inner(input, this.maxRetries); } @@ -44,16 +84,28 @@ export class SendGridService extends EmailProvider { ]; } + return this.send_inner_params(emailParams, retries, isMultipleRecipients); + } + + private async send_inner_params( + emailParams: MailDataRequired, + retries: number, + isMultipleRecipients = false, + ): Promise { const callBack = (error) => { if (error) { console.error( - `Error sending email to: ${ - isMultipleRecipients ? to.toString() : to - }! Error body:`, + `Error sending email to: ${JSON.stringify( + emailParams.to, + )}! Error body:`, error?.response?.body || error, ); if (retries > 0) { - void this.send_inner(input, retries - 1); + void this.send_inner_params( + emailParams, + retries - 1, + isMultipleRecipients, + ); } } }; diff --git a/api/src/services/user.service.ts b/api/src/services/user.service.ts index cc51f0b0a0a..89634dbe9da 100644 --- a/api/src/services/user.service.ts +++ b/api/src/services/user.service.ts @@ -60,6 +60,7 @@ import { SnapshotCreateService } from './snapshot-create.service'; import { toAddHelper, toRemoveHelper } from '../utilities/snapshot-helpers'; import { AdvocateUserAccept } from '../dtos/users/advocate-user-accept.dto'; import { UserNotificationPreferences } from '../dtos/users/user-notification-preferences.dto'; +import { UnsubscribeAllDto } from '../dtos/users/unsubscribe-all.dto'; /* this is the service for users @@ -1752,6 +1753,57 @@ export class UserService { return mapTo(UserNotificationPreferences, notificationPreferences); } + async unsubscribeAll(dto: UnsubscribeAllDto): Promise { + const expectedSig = crypto + .createHmac('sha256', process.env.APP_SECRET) + .update(dto.email) + .digest('hex'); + + if (expectedSig !== dto.sig) { + throw new UnauthorizedException('Invalid unsubscribe token'); + } + + const user = await this.prisma.userAccounts.findUnique({ + where: { email: dto.email }, + select: { id: true }, + }); + + if (user) { + await this.prisma.userNotificationPreferences.upsert({ + create: { + userId: user.id, + lottery: false, + waitlist: false, + mobility: false, + hearing: false, + vision: false, + hearingAndVision: false, + mobilityAndHearing: false, + mobilityAndVision: false, + mobilityHearingAndVision: false, + wantsRegionNotifs: false, + regions: [], + }, + update: { + lottery: false, + waitlist: false, + mobility: false, + hearing: false, + vision: false, + hearingAndVision: false, + mobilityAndHearing: false, + mobilityAndVision: false, + mobilityHearingAndVision: false, + wantsRegionNotifs: false, + regions: [], + }, + where: { userId: user.id }, + }); + } + + return { success: true }; + } + async updatePreferences( dto: UserNotificationPreferences, requestingUser: User, diff --git a/api/src/views/listing-opportunity.hbs b/api/src/views/listing-opportunity.hbs index 24ca09add67..798cde88df7 100644 --- a/api/src/views/listing-opportunity.hbs +++ b/api/src/views/listing-opportunity.hbs @@ -49,7 +49,12 @@ {{/if}} - {{t "rentalOpportunity.footer.unsubscribeAndEmailSettings"}} + {{t "rentalOpportunity.footer.unsubscribeFromAll"}} + + + + + {{t "rentalOpportunity.footer.manageSubscriptions"}} diff --git a/api/test/unit/services/email.service.spec.ts b/api/test/unit/services/email.service.spec.ts index 949ef2adaaa..afd88601c09 100644 --- a/api/test/unit/services/email.service.spec.ts +++ b/api/test/unit/services/email.service.spec.ts @@ -1,3 +1,4 @@ +import { createHmac } from 'crypto'; import { Test, TestingModule } from '@nestjs/testing'; import { ConfigModule } from '@nestjs/config'; import { @@ -1497,4 +1498,123 @@ describe('Testing email service', () => { }); }); }); + + describe('listingPublishNotification', () => { + const TEST_SECRET = 'test-app-secret'; + const PUBLIC_URL = 'https://example.com'; + + const expectedSig = (email: string) => + createHmac('sha256', TEST_SECRET).update(email).digest('hex'); + + const expectedUnsubscribeUrl = (email: string) => + `${PUBLIC_URL}/unsubscribe?email=${encodeURIComponent( + email, + )}&sig=${expectedSig(email)}`; + + const baseListing = (): Listing => + ({ + id: 'listing-id', + name: 'Test Listing', + urlSlug: 'test-listing', + units: [], + listingsBuildingAddress: yellowstoneAddress, + listingEvents: [], + accessibleMarketingFlyer: undefined, + } as unknown as Listing); + + beforeEach(() => { + process.env.APP_SECRET = TEST_SECRET; + service.polyglot.replace(translationFactory().translations); + jest.spyOn(jurisdictionServiceMock, 'findOne').mockReturnValue({ + id: 'jurisdictionId', + name: 'Jurisdiction 1', + publicUrl: PUBLIC_URL, + emailFromAddress: 'no-reply@example.com', + languages: ['en'], + } as any); + }); + + it('sends one bulk call per language with all recipients', async () => { + const emails = ['a@example.com', 'b@example.com']; + await service.listingPublishNotification( + { id: 'jurisdictionId' }, + baseListing(), + [], + { en: emails }, + ); + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][0].to).toEqual(emails); + }); + + it('body contains the -unsubscribeUrl- placeholder, not a resolved URL', async () => { + await service.listingPublishNotification( + { id: 'jurisdictionId' }, + baseListing(), + [], + { en: ['user@example.com'] }, + ); + + const body: string = sendMock.mock.calls[0][0].body; + expect(body).toContain('-unsubscribeUrl-'); + expect(body).not.toContain('unsubscribe?email='); + }); + + it('passes perRecipientData with a correct HMAC-signed URL per recipient', async () => { + const emails = ['a@example.com', 'b@example.com']; + await service.listingPublishNotification( + { id: 'jurisdictionId' }, + baseListing(), + [], + { en: emails }, + ); + + const perRecipientData: { unsubscribeUrl: string }[] = + sendMock.mock.calls[0][0].perRecipientData; + + expect(perRecipientData).toHaveLength(2); + expect(perRecipientData[0].unsubscribeUrl).toBe( + expectedUnsubscribeUrl('a@example.com'), + ); + expect(perRecipientData[1].unsubscribeUrl).toBe( + expectedUnsubscribeUrl('b@example.com'), + ); + }); + + it('body contains the manage subscriptions link', async () => { + await service.listingPublishNotification( + { id: 'jurisdictionId' }, + baseListing(), + [], + { en: ['user@example.com'] }, + ); + + const body: string = sendMock.mock.calls[0][0].body; + expect(body).toContain( + `${PUBLIC_URL}/sign-in?redirectUrl=/account/notifications`, + ); + }); + + it('sends separate bulk calls for each language', async () => { + await service.listingPublishNotification( + { id: 'jurisdictionId' }, + baseListing(), + [], + { en: ['en@example.com'], es: ['es@example.com'] }, + ); + + expect(sendMock).toHaveBeenCalledTimes(2); + }); + + it('skips languages with no recipients', async () => { + await service.listingPublishNotification( + { id: 'jurisdictionId' }, + baseListing(), + [], + { en: ['user@example.com'], es: [] }, + ); + + expect(sendMock).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/api/test/unit/services/user.service.spec.ts b/api/test/unit/services/user.service.spec.ts index 08e46387fe3..bec1a8cdf23 100644 --- a/api/test/unit/services/user.service.spec.ts +++ b/api/test/unit/services/user.service.spec.ts @@ -3,7 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Test, TestingModule } from '@nestjs/testing'; import { LanguagesEnum } from '@prisma/client'; -import { randomUUID } from 'crypto'; +import { createHmac, randomUUID } from 'crypto'; import { Request } from 'express'; import { verify } from 'jsonwebtoken'; import { IdDTO } from '../../../src/dtos/shared/id.dto'; @@ -32,6 +32,7 @@ import { AdvocateUserUpdate } from '../../../src/dtos/users/advocate-user-update import { UserOrderByKeys } from '../../../src/enums/listings/order-by-enum'; import { Jurisdiction } from '../../../src/dtos/jurisdictions/jurisdiction.dto'; import { UserNotificationPreferences } from '../../../src/dtos/users/user-notification-preferences.dto'; +import { UnsubscribeAllDto } from '../../../src/dtos/users/unsubscribe-all.dto'; describe('Testing user service', () => { let service: UserService; @@ -4327,4 +4328,68 @@ describe('Testing user service', () => { }); }); }); + + describe('unsubscribeAll', () => { + const TEST_SECRET = 'test-app-secret'; + const TEST_EMAIL = 'user@example.com'; + + const validSig = () => + createHmac('sha256', TEST_SECRET).update(TEST_EMAIL).digest('hex'); + + const validDto = (): UnsubscribeAllDto => ({ + email: TEST_EMAIL, + sig: validSig(), + }); + + beforeEach(() => { + process.env.APP_SECRET = TEST_SECRET; + }); + + it('returns success and upserts all prefs to false when user exists', async () => { + const userId = randomUUID(); + prisma.userAccounts.findUnique = jest + .fn() + .mockResolvedValue({ id: userId }); + prisma.userNotificationPreferences.upsert = jest + .fn() + .mockResolvedValue({}); + + const result = await service.unsubscribeAll(validDto()); + + expect(result).toEqual({ success: true }); + expect(prisma.userNotificationPreferences.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId }, + update: expect.objectContaining({ + lottery: false, + waitlist: false, + wantsRegionNotifs: false, + regions: [], + }), + }), + ); + }); + + it('returns success without upsert when user is not found', async () => { + prisma.userAccounts.findUnique = jest.fn().mockResolvedValue(null); + prisma.userNotificationPreferences.upsert = jest.fn(); + + const result = await service.unsubscribeAll(validDto()); + + expect(result).toEqual({ success: true }); + expect(prisma.userNotificationPreferences.upsert).not.toHaveBeenCalled(); + }); + + it('throws UnauthorizedException when HMAC signature is invalid', async () => { + const dto: UnsubscribeAllDto = { + email: TEST_EMAIL, + sig: 'bad-signature', + }; + + await expect(service.unsubscribeAll(dto)).rejects.toThrow( + 'Invalid unsubscribe token', + ); + expect(prisma.userAccounts.findUnique).not.toHaveBeenCalled(); + }); + }); }); diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index d6cf460d7bb..06faee30965 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -103,6 +103,11 @@ "account.settings.notifications.visionNote": "Receive notifications for new units with vision accessibility features", "account.settings.notifications.waitlistLabel": "New waitlists", "account.settings.notifications.waitlistNote": "Receive notifications when new waitlists become available", + "account.unsubscribe.errorMessage": "This unsubscribe link is invalid or has expired. Please sign in to manage your notification preferences.", + "account.unsubscribe.errorTitle": "Unable to unsubscribe", + "account.unsubscribe.manageSubscriptions": "Manage subscriptions", + "account.unsubscribe.successMessage": "You have been unsubscribed from all email notifications. You can update your preferences at any time from your account settings.", + "account.unsubscribe.successTitle": "Successfully unsubscribed", "account.settings.passwordRemember": "When changing your password make sure you make note of it so you remember it in the future.", "account.settings.placeholders.day": "DD", "account.settings.placeholders.month": "MM", @@ -1354,6 +1359,7 @@ "t.listingsList": "Listings list", "t.listingsMap": "Listings map", "t.loading": "Loading", + "t.unsubscribe": "Unsubscribe", "t.loginIsRequired": "Login is required to view this page.", "t.mainContent": "Main content", "t.mapControls": "Map Controls", diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 1b49402dfd9..45ae5d21171 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -2019,6 +2019,28 @@ export class UserService { axios(configs, resolve, reject) }) } + /** + * Unsubscribe from all notifications + */ + unsubscribeFromAll( + params: { + /** requestBody */ + body?: UnsubscribeAllDto + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/unsubscribe-from-all" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + + axios(configs, resolve, reject) + }) + } /** * Creates a public only user */ @@ -9912,6 +9934,15 @@ export interface UserDeleteDTO { shouldRemoveApplication?: boolean } +/** UnsubscribeAllDto */ +export interface UnsubscribeAllDto { + /** */ + email: string + + /** */ + sig: string +} + /** RequestSingleUseCode */ export interface RequestSingleUseCode { /** */ diff --git a/sites/public/__tests__/pages/unsubscribe.test.tsx b/sites/public/__tests__/pages/unsubscribe.test.tsx new file mode 100644 index 00000000000..6ee9d87d81b --- /dev/null +++ b/sites/public/__tests__/pages/unsubscribe.test.tsx @@ -0,0 +1,93 @@ +import React from "react" +import userEvent from "@testing-library/user-event" +import { AuthContext } from "@bloom-housing/shared-helpers" +import { UserService } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { mockNextRouter, render, screen, waitFor } from "../testUtils" +import Unsubscribe from "../../src/pages/unsubscribe" + +jest.mock("next/router", () => ({ + useRouter: jest.fn(), +})) + +const mockUnsubscribeFromAll = jest.fn() + +const renderPage = (email?: string, sig?: string, initialStateLoaded = true) => { + const routerMocks = mockNextRouter(email && sig ? { email, sig } : {}) + render( + + + + ) + return routerMocks +} + +beforeAll(() => { + window.scrollTo = jest.fn() +}) + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe("Unsubscribe Page", () => { + it("shows loading state before auth has initialized", () => { + renderPage("test@example.com", "abc123", false) + expect(screen.queryByText("Successfully unsubscribed")).not.toBeInTheDocument() + expect(screen.queryByText("Unable to unsubscribe")).not.toBeInTheDocument() + expect(mockUnsubscribeFromAll).not.toHaveBeenCalled() + }) + + it("shows success state after a valid unsubscribe call", async () => { + mockUnsubscribeFromAll.mockResolvedValueOnce({ success: true }) + renderPage("test@example.com", "valid-sig") + + await waitFor(() => + expect(mockUnsubscribeFromAll).toHaveBeenCalledWith({ + body: { email: "test@example.com", sig: "valid-sig" }, + }) + ) + + expect(await screen.findByText("Successfully unsubscribed")).toBeInTheDocument() + expect( + screen.getByText( + "You have been unsubscribed from all email notifications. You can update your preferences at any time from your account settings." + ) + ).toBeInTheDocument() + }) + + it("shows error state when the API call fails", async () => { + mockUnsubscribeFromAll.mockRejectedValueOnce(new Error("invalid token")) + renderPage("test@example.com", "bad-sig") + + expect(await screen.findByText("Unable to unsubscribe")).toBeInTheDocument() + expect( + screen.getByText( + "This unsubscribe link is invalid or has expired. Please sign in to manage your notification preferences." + ) + ).toBeInTheDocument() + }) + + it("shows error state when email or sig params are missing", async () => { + renderPage() + + expect(await screen.findByText("Unable to unsubscribe")).toBeInTheDocument() + expect(mockUnsubscribeFromAll).not.toHaveBeenCalled() + }) + + it("navigates to /account/notifications when manage subscriptions is clicked", async () => { + mockUnsubscribeFromAll.mockResolvedValueOnce({ success: true }) + const { pushMock } = renderPage("test@example.com", "valid-sig") + + const button = await screen.findByRole("button", { name: "Manage subscriptions" }) + await userEvent.click(button) + + expect(pushMock).toHaveBeenCalledWith("/account/notifications") + }) +}) diff --git a/sites/public/src/pages/unsubscribe.tsx b/sites/public/src/pages/unsubscribe.tsx new file mode 100644 index 00000000000..424e88758c7 --- /dev/null +++ b/sites/public/src/pages/unsubscribe.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState, useContext } from "react" +import { useRouter } from "next/router" +import { t } from "@bloom-housing/ui-components" +import { AuthContext, BloomCard } from "@bloom-housing/shared-helpers" +import { CardSection } from "@bloom-housing/ui-seeds/src/blocks/Card" +import { Button, LoadingState } from "@bloom-housing/ui-seeds" +import FormsLayout from "../layouts/forms" + +type UnsubscribeStatus = "loading" | "success" | "error" + +const Unsubscribe = () => { + const router = useRouter() + const { userService, initialStateLoaded } = useContext(AuthContext) + const [status, setStatus] = useState("loading") + + useEffect(() => { + if (!initialStateLoaded) return + + const email = router.query?.email as string + const sig = router.query?.sig as string + + if (!email || !sig) { + setStatus("error") + return + } + + void userService + .unsubscribeFromAll({ body: { email, sig } }) + .then(() => setStatus("success")) + .catch(() => setStatus("error")) + }, [initialStateLoaded, router.query, userService]) + + return ( + + + + + {status === "success" && ( + <> +

{t("account.unsubscribe.successMessage")}

+ + + )} + {status === "error" &&

{t("account.unsubscribe.errorMessage")}

} +
+
+
+
+ ) +} + +export default Unsubscribe