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:
+26
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+23
@@ -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.';
|
||||
}
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+44
@@ -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;
|
||||
});
|
||||
};
|
||||
+24
@@ -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 },
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user