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.
This commit is contained in:
Félix Malfait
2026-05-28 18:10:18 +02:00
parent 90e1e1596d
commit 48a9a8a50c
5 changed files with 152 additions and 0 deletions
@@ -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<void> {
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<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS "core"."IDX_MESSAGE_CHANNEL_WORKSPACE_TRANSACTIONAL_UNIQUE"`,
);
}
}
@@ -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.';
}
}
@@ -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<EmailingDomainDTO[]> {
return this.emailingDomainService.getEmailingDomains(currentWorkspace);
}
}
@@ -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<string, unknown>);
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;
});
};
@@ -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<typeof createDOMPurify> | null = null;
const getPurify = (): ReturnType<typeof createDOMPurify> => {
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 },
});
};