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,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 };
};