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,33 @@
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util';
describe('SendEmailTool - parseCommaSeparatedEmails', () => {
it('should parse comma-separated emails into array', () => {
expect(
parseCommaSeparatedEmails('a@test.com, b@test.com, c@test.com'),
).toEqual(['a@test.com', 'b@test.com', 'c@test.com']);
});
it('should handle workflow variables mixed with emails', () => {
expect(
parseCommaSeparatedEmails(
'{{trigger.record.email}}, static@example.com, {{user.email}}',
),
).toEqual([
'{{trigger.record.email}}',
'static@example.com',
'{{user.email}}',
]);
});
it('should filter out empty entries and handle irregular spacing', () => {
expect(
parseCommaSeparatedEmails(' a@test.com , , b@test.com, '),
).toEqual(['a@test.com', 'b@test.com']);
});
it('should return empty array for undefined or empty input', () => {
expect(parseCommaSeparatedEmails(undefined)).toEqual([]);
expect(parseCommaSeparatedEmails('')).toEqual([]);
expect(parseCommaSeparatedEmails(' ')).toEqual([]);
});
});
@@ -2,8 +2,27 @@ import { isValidUuid } from 'twenty-shared/utils';
import { workflowFileSchema } from 'twenty-shared/workflow';
import { z } from 'zod';
const EmailRecipientsZodSchema = z.object({
to: z
.string()
.describe('Comma-separated recipient email addresses (To)')
.default(''),
cc: z
.string()
.describe('Comma-separated CC email addresses')
.optional()
.default(''),
bcc: z
.string()
.describe('Comma-separated BCC email addresses')
.optional()
.default(''),
});
export const SendEmailInputZodSchema = z.object({
email: z.email().describe('The recipient email address'),
recipients: EmailRecipientsZodSchema.describe(
'Recipients object with to, cc, and bcc fields (comma-separated)',
),
subject: z.string().describe('The email subject line'),
body: z.string().describe('The email body content (HTML or plain text)'),
connectedAccountId: z
@@ -20,6 +20,7 @@ import {
} from 'src/engine/core-modules/tool/tools/send-email-tool/exceptions/send-email-tool.exception';
import { SendEmailInputZodSchema } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema';
import { type SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
@@ -121,6 +122,59 @@ export class SendEmailTool implements Tool {
);
}
private normalizeRecipients(parameters: SendEmailInput): {
to: string[];
cc: string[];
bcc: string[];
} {
if (
!parameters.recipients ||
!parameters.recipients.to ||
parameters.recipients.to.trim().length === 0
) {
throw new SendEmailToolException(
'No recipients specified',
SendEmailToolExceptionCode.INVALID_EMAIL,
);
}
const to = parseCommaSeparatedEmails(parameters.recipients.to);
if (to.length === 0) {
throw new SendEmailToolException(
'No valid recipients specified',
SendEmailToolExceptionCode.INVALID_EMAIL,
);
}
return {
to,
cc: parseCommaSeparatedEmails(parameters.recipients.cc),
bcc: parseCommaSeparatedEmails(parameters.recipients.bcc),
};
}
private validateEmails(recipients: {
to: string[];
cc: string[];
bcc: string[];
}): string[] {
const emailSchema = z.string().trim().pipe(z.email());
const invalidEmails: string[] = [];
const allEmails = [...recipients.to, ...recipients.cc, ...recipients.bcc];
for (const email of allEmails) {
const result = emailSchema.safeParse(email);
if (!result.success) {
invalidEmails.push(email);
}
}
return invalidEmails;
}
private async getAttachments(
files: Array<{ id: string; name: string; type: string }>,
workspaceId: string,
@@ -186,23 +240,35 @@ export class SendEmailTool implements Tool {
context: ToolExecutionContext,
): Promise<ToolOutput> {
const { workspaceId } = context;
const { email, subject, body, files } = parameters;
const { subject, body, files } = parameters;
let { connectedAccountId } = parameters;
let recipients: { to: string[]; cc: string[]; bcc: string[] };
try {
const emailSchema = z
.string()
.trim()
.pipe(z.email({ error: 'Invalid email' }));
const emailValidation = emailSchema.safeParse(email);
recipients = this.normalizeRecipients(parameters);
} catch (error) {
return {
success: false,
message: 'No recipients specified',
error:
error instanceof Error ? error.message : 'No recipients specified',
};
}
if (!emailValidation.success) {
throw new SendEmailToolException(
`Email '${email}' is invalid`,
SendEmailToolExceptionCode.INVALID_EMAIL,
);
}
const invalidEmails = this.validateEmails(recipients);
if (invalidEmails.length > 0) {
return {
success: false,
message: `Invalid email addresses: ${invalidEmails.join(', ')}`,
error: `Invalid email addresses: ${invalidEmails.join(', ')}`,
};
}
const toRecipientsDisplay = recipients.to.join(', ');
try {
if (!connectedAccountId) {
connectedAccountId =
await this.getOrThrowFirstConnectedAccountId(workspaceId);
@@ -213,16 +279,23 @@ export class SendEmailTool implements Tool {
workspaceId,
);
const messageChannelId = connectedAccount.messageChannels.find(
const messageChannel = connectedAccount.messageChannels.find(
(channel) => channel.handle === connectedAccount.handle,
)?.id!;
);
if (!isDefined(messageChannel)) {
throw new SendEmailToolException(
`No message channel found for connected account '${connectedAccountId}'`,
SendEmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
const { accessToken, refreshToken } =
await this.messagingAccountAuthenticationService.validateAndRefreshConnectedAccountAuthentication(
{
connectedAccount,
workspaceId,
messageChannelId,
messageChannelId: messageChannel.id,
},
);
@@ -247,7 +320,9 @@ export class SendEmailTool implements Tool {
await this.sendMessageService.sendMessage(
{
to: email,
to: recipients.to,
cc: recipients.cc.length > 0 ? recipients.cc : undefined,
bcc: recipients.bcc.length > 0 ? recipients.bcc : undefined,
subject: safeSubject,
body: textBody,
html: safeHtmlBody,
@@ -257,14 +332,16 @@ export class SendEmailTool implements Tool {
);
this.logger.log(
`Email sent successfully to ${email}${attachments.length > 0 ? ` with ${attachments.length} attachments` : ''}`,
`Email sent successfully to ${toRecipientsDisplay}${attachments.length > 0 ? ` with ${attachments.length} attachments` : ''}`,
);
return {
success: true,
message: `Email sent successfully to ${email}`,
message: `Email sent successfully to ${toRecipientsDisplay}`,
result: {
recipient: email,
recipients: recipients.to,
ccRecipients: recipients.cc,
bccRecipients: recipients.bcc,
subject: safeSubject,
connectedAccountId,
attachmentCount: attachments.length,
@@ -274,7 +351,7 @@ export class SendEmailTool implements Tool {
if (error instanceof SendEmailToolException) {
return {
success: false,
message: `Failed to send email to ${email}`,
message: `Failed to send email to ${toRecipientsDisplay}`,
error: error.message,
};
}
@@ -283,7 +360,7 @@ export class SendEmailTool implements Tool {
return {
success: false,
message: `Failed to send email to ${email}`,
message: `Failed to send email to ${toRecipientsDisplay}`,
error: error instanceof Error ? error.message : 'Failed to send email',
};
}
@@ -0,0 +1,14 @@
import { isDefined } from 'twenty-shared/utils';
export const parseCommaSeparatedEmails = (
value: string | undefined,
): string[] => {
if (!isDefined(value)) {
return [];
}
return value
.split(',')
.map((email) => email.trim())
.filter((email) => email.length > 0);
};