import { BadRequestException, Body, Controller, Get, Header, HttpCode, Post, Query, UseGuards, } from '@nestjs/common'; import { isNonEmptyString } from '@sniptt/guards'; import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service'; import { MessageSuppressionReason } from 'src/engine/core-modules/emailing-domain/types/message-suppression-reason.type'; import { MessageSuppressionSource } from 'src/engine/core-modules/emailing-domain/types/message-suppression-source.type'; import { type UnsubscribeTokenPayload } from 'src/engine/core-modules/emailing-domain/types/unsubscribe-token-payload.type'; import { buildUnsubscribePreferencesPage } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-preferences-page.util'; import { buildUnsubscribeResultPage } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-result-page.util'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; import { MessageSuppressionService } from 'src/modules/emailing/services/message-suppression.service'; const UNSUBSCRIBE_TOKEN_FORMAT = /^[A-Za-z0-9_-]{1,1024}$/; const UPDATE_PREFERENCES_PATH = '/emailing/unsubscribe/preferences'; const UNSUBSCRIBE_ALL_PATH = '/emailing/unsubscribe/all'; const HTML_CONTENT_TYPE = 'text/html; charset=utf-8'; const PREVIEW_RESULT_PAGE = buildUnsubscribeResultPage( 'Preview', 'This is a preview — no changes were saved.', ); type UnsubscribeFormBody = { t?: string; unsubscribeTopicId?: string | string[]; }; @Controller('emailing/unsubscribe') @UseGuards(PublicEndpointGuard, NoPermissionGuard) export class UnsubscribeController { constructor( private readonly unsubscribeTokenService: UnsubscribeTokenService, private readonly messageSuppressionService: MessageSuppressionService, ) {} @Post() @HttpCode(200) async handleOneClickUnsubscribe(@Query('t') token: string): Promise { const payload = this.verifyTokenOrThrow(token); if (payload.preview === true) { return; } await this.messageSuppressionService.suppress({ workspaceId: payload.workspaceId, emailAddress: payload.emailAddress, reason: MessageSuppressionReason.UNSUBSCRIBE, source: MessageSuppressionSource.SYSTEM, unsubscribeTopicId: payload.unsubscribeTopicId ?? null, }); } @Get() @Header('Content-Type', HTML_CONTENT_TYPE) async handlePreferencesPage(@Query('t') token: string): Promise { const payload = this.verifyTokenOrThrow(token); const topics = await this.messageSuppressionService.getTopicOptOutState({ workspaceId: payload.workspaceId, emailAddress: payload.emailAddress, }); return buildUnsubscribePreferencesPage({ token, topics, updatePath: UPDATE_PREFERENCES_PATH, unsubscribeAllPath: UNSUBSCRIBE_ALL_PATH, }); } @Post('preferences') @Header('Content-Type', HTML_CONTENT_TYPE) async handleUpdatePreferences( @Body() body: UnsubscribeFormBody, ): Promise { const payload = this.verifyTokenOrThrow(body.t); if (payload.preview === true) { return PREVIEW_RESULT_PAGE; } await this.messageSuppressionService.setTopicOptOuts({ workspaceId: payload.workspaceId, emailAddress: payload.emailAddress, keptTopicIds: this.normalizeTopicIds(body.unsubscribeTopicId), }); return buildUnsubscribeResultPage( 'Preferences updated', 'Your email preferences have been saved.', ); } @Post('all') @Header('Content-Type', HTML_CONTENT_TYPE) async handleUnsubscribeAll( @Body() body: UnsubscribeFormBody, ): Promise { const payload = this.verifyTokenOrThrow(body.t); if (payload.preview === true) { return PREVIEW_RESULT_PAGE; } await this.messageSuppressionService.suppress({ workspaceId: payload.workspaceId, emailAddress: payload.emailAddress, reason: MessageSuppressionReason.UNSUBSCRIBE, source: MessageSuppressionSource.SYSTEM, }); return buildUnsubscribeResultPage( 'You have been unsubscribed', 'You will no longer receive marketing emails from this sender.', ); } private normalizeTopicIds( unsubscribeTopicId: string | string[] | undefined, ): string[] { if (Array.isArray(unsubscribeTopicId)) { return unsubscribeTopicId.filter(isNonEmptyString); } return isNonEmptyString(unsubscribeTopicId) ? [unsubscribeTopicId] : []; } private verifyTokenOrThrow( token: string | undefined, ): UnsubscribeTokenPayload { if (!isNonEmptyString(token) || !UNSUBSCRIBE_TOKEN_FORMAT.test(token)) { throw new BadRequestException('Malformed unsubscribe token'); } const payload = this.unsubscribeTokenService.verify(token); if (payload === null) { throw new BadRequestException('Invalid unsubscribe token'); } return payload; } }