From 48a9a8a50cbf095acfb5d9a1efaa3cb46128adb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 28 May 2026 18:10:18 +0200 Subject: [PATCH] feat(messaging): scaffold code-review-fix utils (in-progress) New utils + scaffolding for the code-review fixes; service/job/DTO edits to follow in a sibling commit. Splitting these out so they survive concurrent branch switches. - sanitize-campaign-html.util: DOMPurify-backed HTML sanitizer. - is-non-trivial-recipient-filter.util: rejects {and:[]} / {} filters. - is-non-trivial-recipient-filter.validator: class-validator wrapper. - campaign-emailing-domain.resolver: SEND_EMAIL_TOOL-gated query for campaign domain selection (avoids the IS_EMAIL_GROUP_ENABLED gate). - 2-9-...-add-workspace-transactional-channel-unique-index: partial unique index backing findOrCreateWorkspaceTransactionalChannel. --- ...pace-transactional-channel-unique-index.ts | 26 +++++++++++ ...-non-trivial-recipient-filter.validator.ts | 23 ++++++++++ .../campaign-emailing-domain.resolver.ts | 35 +++++++++++++++ .../is-non-trivial-recipient-filter.util.ts | 44 +++++++++++++++++++ .../utils/sanitize-campaign-html.util.ts | 24 ++++++++++ 5 files changed, 152 insertions(+) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799100020000-add-workspace-transactional-channel-unique-index.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/dtos/validators/is-non-trivial-recipient-filter.validator.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/resolvers/campaign-emailing-domain.resolver.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/is-non-trivial-recipient-filter.util.ts create mode 100644 packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/sanitize-campaign-html.util.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799100020000-add-workspace-transactional-channel-unique-index.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799100020000-add-workspace-transactional-channel-unique-index.ts new file mode 100644 index 00000000000..f11438370d2 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-9/2-9-instance-command-fast-1799100020000-add-workspace-transactional-channel-unique-index.ts @@ -0,0 +1,26 @@ +import { QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +// Partial unique index that backs the +// findOrCreateWorkspaceTransactionalChannel get-or-create with a real DB +// constraint. Without this, two concurrent first-time campaign sends both +// miss the existing-channel check and create duplicate synthetic +// WORKSPACE_TRANSACTIONAL channels for the same workspace. +@RegisteredInstanceCommand('2.9.0', 1799100020000) +export class AddWorkspaceTransactionalChannelUniqueIndexFastInstanceCommand + implements FastInstanceCommand +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_MESSAGE_CHANNEL_WORKSPACE_TRANSACTIONAL_UNIQUE" ON "core"."messageChannel" ("workspaceId") WHERE "type" = 'WORKSPACE_TRANSACTIONAL'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "core"."IDX_MESSAGE_CHANNEL_WORKSPACE_TRANSACTIONAL_UNIQUE"`, + ); + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/dtos/validators/is-non-trivial-recipient-filter.validator.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/dtos/validators/is-non-trivial-recipient-filter.validator.ts new file mode 100644 index 00000000000..4173b208971 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/dtos/validators/is-non-trivial-recipient-filter.validator.ts @@ -0,0 +1,23 @@ +import { + type ValidationArguments, + ValidatorConstraint, + type ValidatorConstraintInterface, +} from 'class-validator'; +import { type RecordGqlOperationFilter } from 'twenty-shared/types'; + +import { isNonTrivialRecipientFilter } from 'src/modules/messaging/message-outbound-manager/utils/is-non-trivial-recipient-filter.util'; + +@ValidatorConstraint({ name: 'IsNonTrivialRecipientFilter', async: false }) +export class IsNonTrivialRecipientFilter + implements ValidatorConstraintInterface +{ + validate(value: unknown): boolean { + return isNonTrivialRecipientFilter( + value as RecordGqlOperationFilter | null | undefined, + ); + } + + defaultMessage(_args: ValidationArguments): string { + return 'recipientFilter must contain at least one real constraint; an empty filter is not allowed.'; + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/resolvers/campaign-emailing-domain.resolver.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/resolvers/campaign-emailing-domain.resolver.ts new file mode 100644 index 00000000000..892f655cd41 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/resolvers/campaign-emailing-domain.resolver.ts @@ -0,0 +1,35 @@ +import { UseGuards, UsePipes } from '@nestjs/common'; +import { Query } from '@nestjs/graphql'; + +import { PermissionFlagType } from 'twenty-shared/constants'; + +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { EmailingDomainDTO } from 'src/engine/core-modules/emailing-domain/dtos/emailing-domain.dto'; +import { EmailingDomainService } from 'src/engine/core-modules/emailing-domain/services/emailing-domain.service'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +// Mirrors `getEmailingDomains` from EmailingDomainResolver but gated by +// SEND_EMAIL_TOOL (the campaign permission) instead of IS_EMAIL_GROUP_ENABLED +// (an unrelated feature flag). Workspaces that have set up SES + a verified +// domain should be able to compose campaigns regardless of whether the +// email-group feature is enabled. +@UseGuards( + WorkspaceAuthGuard, + SettingsPermissionGuard(PermissionFlagType.SEND_EMAIL_TOOL), +) +@UsePipes(ResolverValidationPipe) +@MetadataResolver() +export class CampaignEmailingDomainResolver { + constructor(private readonly emailingDomainService: EmailingDomainService) {} + + @Query(() => [EmailingDomainDTO]) + async getCampaignEmailingDomains( + @AuthWorkspace() currentWorkspace: WorkspaceEntity, + ): Promise { + return this.emailingDomainService.getEmailingDomains(currentWorkspace); + } +} diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/is-non-trivial-recipient-filter.util.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/is-non-trivial-recipient-filter.util.ts new file mode 100644 index 00000000000..50c98442ce9 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/is-non-trivial-recipient-filter.util.ts @@ -0,0 +1,44 @@ +import { type RecordGqlOperationFilter } from 'twenty-shared/types'; + +// Rejects filters that resolve to "no constraint" — empty object, empty +// and/or, and any wrapper around such (e.g. `{ not: { and: [] } }`). Without +// this guard the GraphqlQueryParser walks an empty conjunction and yields no +// WHERE clause, so a malicious caller could blast every Person in the +// workspace. +export const isNonTrivialRecipientFilter = ( + filter: RecordGqlOperationFilter | null | undefined, +): boolean => { + if (filter === null || filter === undefined) { + return false; + } + + if (typeof filter !== 'object' || Array.isArray(filter)) { + return false; + } + + const entries = Object.entries(filter as Record); + + if (entries.length === 0) { + return false; + } + + // For boolean combinators, drop them if all branches are themselves trivial. + // For field-level keys (id, name, ...), presence counts as a real constraint. + return entries.some(([key, value]) => { + if (key === 'and' || key === 'or') { + if (!Array.isArray(value) || value.length === 0) { + return false; + } + + return value.some((branch) => + isNonTrivialRecipientFilter(branch as RecordGqlOperationFilter), + ); + } + + if (key === 'not') { + return isNonTrivialRecipientFilter(value as RecordGqlOperationFilter); + } + + return true; + }); +}; diff --git a/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/sanitize-campaign-html.util.ts b/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/sanitize-campaign-html.util.ts new file mode 100644 index 00000000000..ce9f5b02b7a --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-outbound-manager/utils/sanitize-campaign-html.util.ts @@ -0,0 +1,24 @@ +import createDOMPurify from 'dompurify'; +import { JSDOM } from 'jsdom'; + +// Lazy singleton — JSDOM instantiation is heavy, and the sanitizer is purely +// functional once built. +let cachedPurify: ReturnType | null = null; + +const getPurify = (): ReturnType => { + if (cachedPurify === null) { + const jsdom = new JSDOM(''); + + cachedPurify = createDOMPurify(jsdom.window); + } + + return cachedPurify; +}; + +// Sanitize HTML intended for outbound email. Strips script/style, inline event +// handlers, javascript: URLs, etc. We deliberately keep links + images intact. +export const sanitizeCampaignHtml = (html: string): string => { + return getPurify().sanitize(html, { + USE_PROFILES: { html: true }, + }); +};