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:
+85
@@ -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
@@ -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 {}
|
||||
|
||||
+98
@@ -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);
|
||||
});
|
||||
});
|
||||
+95
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user