Workflow Send Email Node Multiple Recipients Support (#17458)

This PR adds support sending emails to multiple recipients

Figma Reference:

https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=88868-88963&t=Ya0csmNlN4xxczvV-11

Demo:


https://github.com/user-attachments/assets/ecaeaaec-fe42-4fb5-96d3-a91d08b30148
This commit is contained in:
neo773
2026-01-30 21:24:21 +05:30
committed by GitHub
parent 5791bd2943
commit cc1ca630fd
32 changed files with 1371 additions and 137 deletions
@@ -0,0 +1,85 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { migrateWorkflowSteps } from 'src/database/commands/upgrade-version-command/1-17/utils/migrate-send-email-step.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Command({
name: 'upgrade:1-17:migrate-send-email-recipients',
description:
'Migrate send email action from legacy email field to recipients object',
})
export class MigrateSendEmailRecipientsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(
MigrateSendEmailRecipientsCommand.name,
);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
) {
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`Running MigrateSendEmailRecipientsCommand for workspace ${workspaceId}`,
);
const workflowVersionRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const workflowVersions = await workflowVersionRepository.find({
select: ['id', 'steps'],
});
let migratedCount = 0;
for (const version of workflowVersions) {
const { migratedSteps, hasChanges } = migrateWorkflowSteps(version.steps);
if (!hasChanges) {
continue;
}
migratedCount++;
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would migrate workflow version ${version.id} in workspace ${workspaceId}`,
);
} else {
await workflowVersionRepository.update(
{ id: version.id },
{ steps: migratedSteps },
);
this.logger.log(
`Migrated workflow version ${version.id} in workspace ${workspaceId}`,
);
}
}
this.logger.log(
`${isDryRun ? '[DRY RUN] Would have migrated' : 'Migrated'} ${migratedCount} workflow version(s) in workspace ${workspaceId}`,
);
}
}
@@ -5,6 +5,7 @@ import { DeleteFileRecordsCommand } from 'src/database/commands/upgrade-version-
import { IdentifyWebhookMetadataCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-identify-webhook-metadata.command';
import { MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-make-webhook-universal-identifier-and-application-id-not-nullable-migration.command';
import { MigrateAttachmentToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-attachment-to-morph-relations.command';
import { MigrateSendEmailRecipientsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-send-email-recipients.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@@ -17,6 +18,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { GlobalWorkspaceDataSourceModule } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
@@ -40,18 +42,21 @@ import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objec
FieldMetadataModule,
ObjectMetadataModule,
ApplicationModule,
GlobalWorkspaceDataSourceModule,
],
providers: [
MigrateAttachmentToMorphRelationsCommand,
IdentifyWebhookMetadataCommand,
MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
DeleteFileRecordsCommand,
MigrateSendEmailRecipientsCommand,
],
exports: [
MigrateAttachmentToMorphRelationsCommand,
IdentifyWebhookMetadataCommand,
MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
DeleteFileRecordsCommand,
MigrateSendEmailRecipientsCommand,
],
})
export class V1_17_UpgradeVersionCommandModule {}
@@ -0,0 +1,98 @@
import {
migrateInput,
migrateWorkflowSteps,
needsMigration,
} from 'src/database/commands/upgrade-version-command/1-17/utils/migrate-send-email-step.util';
const LEGACY_STEP_FROM_PRODUCTION = {
id: '3b8934cd-1dda-4acb-a050-785e04f7f40b',
name: 'Send Email',
type: 'SEND_EMAIL',
valid: false,
position: { x: 0, y: 150 },
settings: {
input: {
body: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"sample"}]}]}',
email: 'sample@gmail.com',
files: [],
subject: 'sample',
connectedAccountId: '',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
describe('needsMigration', () => {
it('returns true for legacy email field', () => {
expect(
needsMigration({ connectedAccountId: '', email: 'test@example.com' }),
).toBe(true);
});
it('returns false when recipients is already used', () => {
expect(
needsMigration({
connectedAccountId: '',
recipients: { to: 'test@example.com' },
}),
).toBe(false);
});
});
describe('migrateInput', () => {
it('converts legacy email to recipients.to', () => {
const result = migrateInput({
connectedAccountId: 'acc-123',
email: 'legacy@example.com',
subject: 'Test',
body: 'Body',
});
expect(result.recipients.to).toBe('legacy@example.com');
expect(result).not.toHaveProperty('email');
});
});
describe('migrateWorkflowSteps', () => {
it('migrates real production workflow with legacy email field', () => {
const { migratedSteps, hasChanges } = migrateWorkflowSteps([
LEGACY_STEP_FROM_PRODUCTION,
]);
expect(hasChanges).toBe(true);
expect(migratedSteps[0].settings.input).toEqual({
body: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"sample"}]}]}',
files: [],
subject: 'sample',
connectedAccountId: '',
recipients: {
to: 'sample@gmail.com',
cc: '',
bcc: '',
},
});
});
it('returns hasChanges false when no migration needed', () => {
const alreadyMigratedStep = {
...LEGACY_STEP_FROM_PRODUCTION,
settings: {
...LEGACY_STEP_FROM_PRODUCTION.settings,
input: {
connectedAccountId: '',
recipients: { to: 'new@example.com', cc: '', bcc: '' },
subject: 'Test',
body: 'Body',
},
},
};
const { hasChanges } = migrateWorkflowSteps([alreadyMigratedStep]);
expect(hasChanges).toBe(false);
});
});
@@ -0,0 +1,95 @@
import { isDefined } from 'twenty-shared/utils';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
type LegacySendEmailInput = {
connectedAccountId: string;
email?: string;
recipients?: {
to?: string;
cc?: string;
bcc?: string;
};
subject?: string;
body?: string;
files?: unknown[];
};
type MigratedSendEmailInput = {
connectedAccountId: string;
recipients: {
to: string;
cc: string;
bcc: string;
};
subject?: string;
body?: string;
files?: unknown[];
};
type WorkflowStep = {
id: string;
type: string;
settings: {
input: LegacySendEmailInput | MigratedSendEmailInput;
};
};
export const needsMigration = (input: LegacySendEmailInput): boolean => {
return isDefined(input.email);
};
export const migrateInput = (
input: LegacySendEmailInput,
): MigratedSendEmailInput => {
const { email, recipients, ...rest } = input;
const toValue = recipients?.to || email || '';
const ccValue = recipients?.cc ?? '';
const bccValue = recipients?.bcc ?? '';
return {
...rest,
recipients: {
to: toValue,
cc: ccValue,
bcc: bccValue,
},
};
};
export const migrateWorkflowSteps = (
steps: unknown,
): { migratedSteps: WorkflowStep[]; hasChanges: boolean } => {
if (!isDefined(steps) || !Array.isArray(steps) || steps.length === 0) {
return { migratedSteps: [], hasChanges: false };
}
const typedSteps = steps as WorkflowStep[];
let hasChanges = false;
const migratedSteps = typedSteps.map((step) => {
if (step.type !== WorkflowActionType.SEND_EMAIL) {
return step;
}
const input = step.settings.input as LegacySendEmailInput;
if (!needsMigration(input)) {
return step;
}
hasChanges = true;
return {
...step,
settings: {
...step.settings,
input: migrateInput(input),
},
};
});
return { migratedSteps, hasChanges };
};