From cc1ca630fd0ec920e012fb30455adc739c955451 Mon Sep 17 00:00:00 2001
From: neo773 <62795688+neo773@users.noreply.github.com>
Date: Fri, 30 Jan 2026 21:24:21 +0530
Subject: [PATCH] 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
---
.../ui/form-types/components/BaseChip.tsx | 104 ++++++++++++
.../FormFieldInputInnerContainer.tsx | 7 +-
.../components/FormMultiTextFieldInput.tsx | 85 ++++++++++
.../components/TextVariableEditor.tsx | 14 +-
.../ui/form-types/components/VariableChip.tsx | 104 +++---------
.../hooks/useMultiItemFieldEditor.ts | 157 ++++++++++++++++++
.../WorkflowEditActionSendEmail.tsx | 143 ++++++++++++++--
.../WorkflowEditActionSendEmail.stories.tsx | 17 +-
.../components/WorkflowTextEditorTextChip.tsx | 27 +++
.../getMultiItemFieldEditorContent.test.ts | 63 +++++++
.../parseMultiItemEditorContent.test.ts | 52 ++++++
.../utils/getMultiItemFieldEditorContent.ts | 40 +++++
.../utils/parseEditorContent.ts | 4 +
.../utils/parseMultiItemEditorContent.ts | 44 +++++
.../workflow-variables/utils/textTag.ts | 64 +++++++
...7-migrate-send-email-recipients.command.ts | 85 ++++++++++
.../1-17-upgrade-version-command.module.ts | 5 +
.../migrate-send-email-step.util.spec.ts | 98 +++++++++++
.../utils/migrate-send-email-step.util.ts | 95 +++++++++++
.../__tests__/send-email-tool.spec.ts | 33 ++++
.../send-email-tool/send-email-tool.schema.ts | 21 ++-
.../tools/send-email-tool/send-email-tool.ts | 119 ++++++++++---
.../parse-comma-separated-emails.util.ts | 14 ++
.../messaging-send-message.service.ts | 27 ++-
.../to-microsoft-recipients.util.spec.ts | 39 +++++
.../utils/to-microsoft-recipients.util.ts | 19 +++
...workflow-version-step-changes.util.spec.ts | 6 +-
...rsion-step-operations.workspace-service.ts | 6 +-
.../workflow-send-email-action-input.type.ts | 4 +-
packages/twenty-shared/src/workflow/index.ts | 1 +
.../send-email-action-settings-schema.ts | 6 +-
.../src/workflow/types/EmailRecipients.ts | 5 +
32 files changed, 1371 insertions(+), 137 deletions(-)
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/BaseChip.tsx
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiTextFieldInput.tsx
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/useMultiItemFieldEditor.ts
create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowTextEditorTextChip.tsx
create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/getMultiItemFieldEditorContent.test.ts
create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/parseMultiItemEditorContent.test.ts
create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/getMultiItemFieldEditorContent.ts
create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/parseMultiItemEditorContent.ts
create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/textTag.ts
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-migrate-send-email-recipients.command.ts
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-17/utils/__tests__/migrate-send-email-step.util.spec.ts
create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-17/utils/migrate-send-email-step.util.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/__tests__/send-email-tool.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util.ts
create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/to-microsoft-recipients.util.spec.ts
create mode 100644 packages/twenty-server/src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util.ts
create mode 100644 packages/twenty-shared/src/workflow/types/EmailRecipients.ts
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/BaseChip.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/BaseChip.tsx
new file mode 100644
index 0000000000..90232c0ca3
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/BaseChip.tsx
@@ -0,0 +1,104 @@
+import { css, useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import { IconX } from 'twenty-ui/display';
+
+const StyledChip = styled.div<{ deletable: boolean; danger: boolean }>`
+ background-color: ${({ theme, danger }) =>
+ danger ? theme.color.red3 : theme.color.blue3};
+ border-width: 1px;
+ border-style: solid;
+ border-color: ${({ theme, danger }) =>
+ danger ? theme.color.red5 : theme.color.blue5};
+ border-radius: 4px;
+ height: 20px;
+ box-sizing: border-box;
+ display: inline-flex;
+ align-items: center;
+ flex-direction: row;
+ flex-shrink: 0;
+ column-gap: ${({ theme }) => theme.spacing(1)};
+ padding-left: ${({ theme }) => theme.spacing(1)};
+ user-select: none;
+ white-space: nowrap;
+
+ ${({ theme, deletable }) =>
+ !deletable
+ ? css`
+ padding-right: ${theme.spacing(1)};
+ `
+ : css`
+ cursor: pointer;
+ `}
+`;
+
+const StyledLabel = styled.span<{ danger: boolean }>`
+ color: ${({ theme, danger }) =>
+ danger ? theme.color.red : theme.color.blue};
+ line-height: 140%;
+`;
+
+const StyledDelete = styled.button<{ danger: boolean }>`
+ box-sizing: border-box;
+ height: 20px;
+ width: 20px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ font-size: ${({ theme }) => theme.font.size.sm};
+ user-select: none;
+ padding: 0;
+ margin: 0;
+ background: none;
+ border: none;
+ color: ${({ theme, danger }) =>
+ danger ? theme.color.red : theme.color.blue};
+ border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
+ border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm};
+
+ &:hover {
+ background-color: ${({ theme, danger }) =>
+ danger ? theme.color.red5 : theme.color.blue5};
+ }
+`;
+
+type BaseChipProps = {
+ label: string;
+ title?: string;
+ onRemove?: () => void;
+ removeAriaLabel?: string;
+ danger?: boolean;
+ leftIcon?: React.ReactNode;
+};
+
+export const BaseChip = ({
+ label,
+ title,
+ onRemove,
+ removeAriaLabel = 'Remove',
+ danger = false,
+ leftIcon,
+}: BaseChipProps) => {
+ const theme = useTheme();
+ const isDeletable = onRemove !== undefined;
+
+ return (
+
+ {leftIcon}
+
+ {label}
+
+
+ {isDeletable && (
+
+
+
+ )}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer.tsx
index 2d4c487979..edab7c4d4e 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer.tsx
@@ -36,7 +36,12 @@ const StyledFormFieldInputInnerContainer = styled.div<
box-sizing: border-box;
display: flex;
- overflow: ${({ multiline }) => (multiline ? 'auto' : 'hidden')};
+ overflow-x: auto;
+ overflow-y: ${({ multiline }) => (multiline ? 'auto' : 'hidden')};
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
width: 100%;
`;
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiTextFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiTextFieldInput.tsx
new file mode 100644
index 0000000000..ab31cb4cca
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormMultiTextFieldInput.tsx
@@ -0,0 +1,85 @@
+import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
+import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
+import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
+import { TextVariableEditor } from '@/object-record/record-field/ui/form-types/components/TextVariableEditor';
+import { useMultiItemFieldEditor } from '@/object-record/record-field/ui/form-types/hooks/useMultiItemFieldEditor';
+import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
+import { InputLabel } from '@/ui/input/components/InputLabel';
+import { parseMultiItemEditorContent } from '@/workflow/workflow-variables/utils/parseMultiItemEditorContent';
+import { t } from '@lingui/core/macro';
+import { useId } from 'react';
+import { isDefined } from 'twenty-shared/utils';
+
+type FormMultiTextFieldInputProps = {
+ label?: string;
+ defaultValue: string | undefined | null;
+ onChange: (value: string) => void;
+ readonly?: boolean;
+ placeholder?: string;
+ VariablePicker?: VariablePickerComponent;
+};
+
+export const FormMultiTextFieldInput = ({
+ label,
+ defaultValue,
+ placeholder,
+ onChange,
+ readonly,
+ VariablePicker,
+}: FormMultiTextFieldInputProps) => {
+ const instanceId = useId();
+
+ const editor = useMultiItemFieldEditor({
+ placeholder: placeholder ?? t`Enter values, comma-separated`,
+ readonly,
+ defaultValue,
+ onUpdate: (editor) => {
+ const jsonContent = editor.getJSON();
+ const parsedContent = parseMultiItemEditorContent(jsonContent);
+
+ onChange(parsedContent);
+ },
+ });
+
+ const handleVariableTagInsert = (variableName: string) => {
+ if (!isDefined(editor)) {
+ throw new Error(
+ 'Expected the editor to be defined when a variable is selected',
+ );
+ }
+
+ editor.commands.insertVariableTag(variableName);
+ };
+
+ if (!isDefined(editor)) {
+ return null;
+ }
+
+ return (
+
+ {label ? {label} : null}
+
+
+
+
+
+
+ {VariablePicker && !readonly ? (
+
+ ) : null}
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/TextVariableEditor.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/TextVariableEditor.tsx
index c185e81ad8..29e332e0ee 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/TextVariableEditor.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/TextVariableEditor.tsx
@@ -21,7 +21,12 @@ const StyledEditor = styled.div<{
box-sizing: border-box;
display: flex;
height: 100%;
- overflow: ${({ multiline }) => (multiline ? 'auto' : 'hidden')};
+ overflow-x: auto;
+ overflow-y: ${({ multiline }) => (multiline ? 'auto' : 'hidden')};
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
color: ${({ theme, readonly }) =>
readonly ? theme.font.color.light : theme.font.color.primary};
font-family: ${({ theme }) => theme.font.family};
@@ -48,6 +53,13 @@ const StyledEditor = styled.div<{
color: ${({ theme }) => theme.color.blue};
padding: ${({ theme }) => theme.spacing(1)};
}
+
+ .text-tag {
+ background-color: ${({ theme }) => theme.color.blue3};
+ border-radius: ${({ theme }) => theme.border.radius.sm};
+ color: ${({ theme }) => theme.color.blue};
+ padding: ${({ theme }) => theme.spacing(1)};
+ }
}
.ProseMirror-focused {
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/VariableChip.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/VariableChip.tsx
index 1371463707..d7f22a1cdd 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/VariableChip.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/VariableChip.tsx
@@ -1,70 +1,10 @@
+import { BaseChip } from '@/object-record/record-field/ui/form-types/components/BaseChip';
import { useSearchVariable } from '@/workflow/workflow-variables/hooks/useSearchVariable';
-import { css, useTheme } from '@emotion/react';
-import styled from '@emotion/styled';
+import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { extractRawVariableNamePart } from 'twenty-shared/workflow';
-import { IconAlertTriangle, IconX } from 'twenty-ui/display';
-
-const StyledChip = styled.div<{ deletable: boolean; danger: boolean }>`
- background-color: ${({ theme, danger }) =>
- danger ? theme.color.red3 : theme.color.blue3};
- border-width: 1px;
- border-style: solid;
- border-color: ${({ theme, danger }) =>
- danger ? theme.color.red5 : theme.color.blue5};
- border-radius: 4px;
- height: 20px;
- box-sizing: border-box;
- display: inline-flex;
- align-items: center;
- flex-direction: row;
- flex-shrink: 0;
- column-gap: ${({ theme }) => theme.spacing(1)};
- padding-left: ${({ theme }) => theme.spacing(1)};
- user-select: none;
- white-space: nowrap;
-
- ${({ theme, deletable }) =>
- !deletable
- ? css`
- padding-right: ${theme.spacing(1)};
- `
- : css`
- cursor: pointer;
- `}
-`;
-
-const StyledLabel = styled.span<{ danger: boolean }>`
- color: ${({ theme, danger }) =>
- danger ? theme.color.red : theme.color.blue};
- line-height: 140%;
-`;
-
-const StyledDelete = styled.button<{ danger: boolean }>`
- box-sizing: border-box;
- height: 20px;
- width: 20px;
- display: flex;
- justify-content: center;
- align-items: center;
- cursor: pointer;
- font-size: ${({ theme }) => theme.font.size.sm};
- user-select: none;
- padding: 0;
- margin: 0;
- background: none;
- border: none;
- color: ${({ theme, danger }) =>
- danger ? theme.color.red : theme.color.blue};
- border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
- border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm};
-
- &:hover {
- background-color: ${({ theme, danger }) =>
- danger ? theme.color.red5 : theme.color.blue5};
- }
-`;
+import { IconAlertTriangle } from 'twenty-ui/display';
type VariableChipProps = {
rawVariableName: string;
@@ -94,27 +34,21 @@ export const VariableChip = ({
const title = isVariableNotFound ? t`Variable not found` : variablePathLabel;
return (
-
- {!isDefined(variableLabel) && (
-
- )}
-
- {label}
-
-
- {onRemove ? (
-
-
-
- ) : null}
-
+
+ ) : undefined
+ }
+ />
);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/useMultiItemFieldEditor.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/useMultiItemFieldEditor.ts
new file mode 100644
index 0000000000..8284fd59c9
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/hooks/useMultiItemFieldEditor.ts
@@ -0,0 +1,157 @@
+import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
+import { getMultiItemFieldEditorContent } from '@/workflow/workflow-variables/utils/getMultiItemFieldEditorContent';
+import { TextTag } from '@/workflow/workflow-variables/utils/textTag';
+import { VariableTag } from '@/workflow/workflow-variables/utils/variableTag';
+import { Extension } from '@tiptap/core';
+import Document from '@tiptap/extension-document';
+import Paragraph from '@tiptap/extension-paragraph';
+import Text from '@tiptap/extension-text';
+import { Placeholder, UndoRedo } from '@tiptap/extensions';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+import { type Editor, useEditor } from '@tiptap/react';
+import { isDefined } from 'twenty-shared/utils';
+
+type UseMultiItemFieldEditorProps = {
+ placeholder: string | undefined;
+ readonly: boolean | undefined;
+ defaultValue: string | undefined | null;
+ onUpdate: (editor: Editor) => void;
+};
+
+const getTextBeforeCursor = (editor: Editor): string => {
+ const { state } = editor;
+ const { selection } = state;
+ const { $from } = selection;
+
+ const textBefore = $from.parent.textBetween(0, $from.parentOffset, '');
+
+ return textBefore;
+};
+
+const convertTextToTag = (editor: Editor, text: string): boolean => {
+ const trimmedText = text.trim();
+ if (trimmedText.length === 0) {
+ return false;
+ }
+
+ const { state } = editor;
+ const { selection } = state;
+ const { $from } = selection;
+
+ const deleteFrom = $from.pos - text.length;
+ const deleteTo = $from.pos;
+
+ editor
+ .chain()
+ .focus()
+ .deleteRange({ from: deleteFrom, to: deleteTo })
+ .insertTextTag(trimmedText)
+ .run();
+
+ return true;
+};
+
+const CommaToTagExtension = Extension.create({
+ name: 'commaToTag',
+
+ addProseMirrorPlugins() {
+ const editor = this.editor;
+
+ return [
+ new Plugin({
+ key: new PluginKey('commaToTag'),
+ props: {
+ handleTextInput: (_view, _from, _to, text) => {
+ if (text === ',') {
+ const textBefore = getTextBeforeCursor(editor);
+
+ if (textBefore.trim().length > 0) {
+ setTimeout(() => {
+ convertTextToTag(editor, textBefore);
+ }, 0);
+ return true;
+ }
+ }
+ return false;
+ },
+ handleKeyDown: (_view, event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ const textBefore = getTextBeforeCursor(editor);
+
+ if (textBefore.trim().length > 0) {
+ setTimeout(() => {
+ convertTextToTag(editor, textBefore);
+ }, 0);
+ return true;
+ }
+ }
+ return false;
+ },
+ handlePaste: (_view, event) => {
+ const pastedText = event.clipboardData?.getData('text/plain');
+
+ if (!pastedText) {
+ return false;
+ }
+
+ const parts = pastedText.split(',');
+
+ if (parts.length <= 1) {
+ return false;
+ }
+
+ parts.forEach((part) => {
+ const trimmedPart = part.trim();
+
+ if (trimmedPart.length === 0) {
+ return;
+ }
+
+ if (isStandaloneVariableString(trimmedPart)) {
+ editor.commands.insertVariableTag(trimmedPart);
+ } else {
+ editor.commands.insertTextTag(trimmedPart);
+ }
+ });
+
+ return true;
+ },
+ },
+ }),
+ ];
+ },
+});
+
+export const useMultiItemFieldEditor = ({
+ placeholder,
+ readonly,
+ defaultValue,
+ onUpdate,
+}: UseMultiItemFieldEditorProps) => {
+ const editor = useEditor({
+ extensions: [
+ Document,
+ Paragraph,
+ Text,
+ Placeholder.configure({
+ placeholder,
+ }),
+ VariableTag,
+ TextTag,
+ CommaToTagExtension,
+ UndoRedo,
+ ],
+ content: isDefined(defaultValue)
+ ? getMultiItemFieldEditorContent(defaultValue)
+ : undefined,
+ editable: !readonly,
+ onUpdate: ({ editor }) => {
+ onUpdate(editor);
+ },
+ enableInputRules: false,
+ enablePasteRules: false,
+ injectCSS: false,
+ });
+
+ return editor;
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx
index 16e9e179c8..d247642369 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx
@@ -8,11 +8,16 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
+import { FormMultiTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiTextFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
+import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
+import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
+import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
+import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
@@ -26,8 +31,10 @@ import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
+import { type EmailRecipients } from 'twenty-shared/workflow';
import { IconPlus } from 'twenty-ui/display';
-import { type SelectOption } from 'twenty-ui/input';
+import { Button, type SelectOption } from 'twenty-ui/input';
+import { MenuItem } from 'twenty-ui/navigation';
import { type JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
@@ -58,7 +65,7 @@ type WorkflowFile = {
type SendEmailFormData = {
connectedAccountId: string;
- email: string;
+ recipients: Required;
subject: string;
body: string;
files: WorkflowFile[];
@@ -82,14 +89,41 @@ export const WorkflowEditActionSendEmail = ({
const redirectUrl = `/object/workflow/${workflowVisualizerWorkflowId}`;
- const [formData, setFormData] = useState({
- connectedAccountId: action.settings.input.connectedAccountId,
- email: action.settings.input.email,
- subject: action.settings.input.subject ?? '',
- body: action.settings.input.body ?? '',
- files: action.settings.input.files ?? [],
+ const [formData, setFormData] = useState(() => {
+ const inputRecipients = action.settings.input.recipients;
+
+ return {
+ connectedAccountId: action.settings.input.connectedAccountId,
+ recipients: {
+ to: inputRecipients?.to ?? '',
+ cc: inputRecipients?.cc ?? '',
+ bcc: inputRecipients?.bcc ?? '',
+ },
+ subject: action.settings.input.subject ?? '',
+ body: action.settings.input.body ?? '',
+ files: action.settings.input.files ?? [],
+ };
});
+ const [visibleAdvancedFields, setVisibleAdvancedFields] = useState<{
+ cc: boolean;
+ bcc: boolean;
+ }>(() => {
+ const inputRecipients = action.settings.input.recipients;
+
+ return {
+ cc: Boolean(inputRecipients?.cc),
+ bcc: Boolean(inputRecipients?.bcc),
+ };
+ });
+
+ const { closeDropdown } = useCloseDropdown();
+
+ const advancedOptionsDropdownId = 'send-email-advanced-options';
+
+ const hasAvailableAdvancedOptions =
+ !visibleAdvancedFields.cc || !visibleAdvancedFields.bcc;
+
const checkConnectedAccountScopes = async (
connectedAccountId: string | null,
) => {
@@ -143,7 +177,7 @@ export const WorkflowEditActionSendEmail = ({
...action.settings,
input: {
connectedAccountId: formData.connectedAccountId,
- email: formData.email,
+ recipients: formData.recipients,
subject: formData.subject,
body: formData.body,
files: formData.files,
@@ -286,16 +320,95 @@ export const WorkflowEditActionSendEmail = ({
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
- {
- handleFieldChange('email', email);
+ defaultValue={formData.recipients.to}
+ onChange={(value) => {
+ handleFieldChange('recipients', {
+ ...formData.recipients,
+ to: value,
+ });
}}
VariablePicker={WorkflowVariablePicker}
/>
+ {visibleAdvancedFields.cc && (
+ {
+ handleFieldChange('recipients', {
+ ...formData.recipients,
+ cc: value,
+ });
+ }}
+ VariablePicker={WorkflowVariablePicker}
+ />
+ )}
+ {visibleAdvancedFields.bcc && (
+ {
+ handleFieldChange('recipients', {
+ ...formData.recipients,
+ bcc: value,
+ });
+ }}
+ VariablePicker={WorkflowVariablePicker}
+ />
+ )}
+ {!actionOptions.readonly && hasAvailableAdvancedOptions && (
+
+ }
+ dropdownComponents={
+
+
+ {!visibleAdvancedFields.cc && (
+
+
+ }
+ />
+ )}
theme.spacing(0.5)};
+`;
+
+type WorkflowTextEditorTextChipProps = NodeViewProps;
+
+export const WorkflowTextEditorTextChip = ({
+ deleteNode,
+ node,
+ editor,
+}: WorkflowTextEditorTextChipProps) => {
+ const text = node.attrs.text as string;
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/getMultiItemFieldEditorContent.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/getMultiItemFieldEditorContent.test.ts
new file mode 100644
index 0000000000..46c78efbfa
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/getMultiItemFieldEditorContent.test.ts
@@ -0,0 +1,63 @@
+import { getMultiItemFieldEditorContent } from '@/workflow/workflow-variables/utils/getMultiItemFieldEditorContent';
+import { parseMultiItemEditorContent } from '@/workflow/workflow-variables/utils/parseMultiItemEditorContent';
+
+describe('getMultiItemFieldEditorContent', () => {
+ it('should parse plain emails as textTags', () => {
+ const result = getMultiItemFieldEditorContent(
+ 'first@example.com, second@example.com',
+ );
+
+ expect(result.content?.[0]?.content).toEqual([
+ { type: 'textTag', attrs: { text: 'first@example.com' } },
+ { type: 'textTag', attrs: { text: 'second@example.com' } },
+ ]);
+ });
+
+ it('should parse standalone variables as variableTags', () => {
+ const result = getMultiItemFieldEditorContent(
+ '{{user.email}}, {{trigger.record.email}}',
+ );
+
+ expect(result.content?.[0]?.content).toEqual([
+ { type: 'variableTag', attrs: { variable: '{{user.email}}' } },
+ { type: 'variableTag', attrs: { variable: '{{trigger.record.email}}' } },
+ ]);
+ });
+
+ it('should handle mixed emails and variables', () => {
+ const result = getMultiItemFieldEditorContent(
+ '{{trigger.record.email}}, sales@company.com, {{user.managerEmail}}',
+ );
+
+ expect(result.content?.[0]?.content).toEqual([
+ { type: 'variableTag', attrs: { variable: '{{trigger.record.email}}' } },
+ { type: 'textTag', attrs: { text: 'sales@company.com' } },
+ { type: 'variableTag', attrs: { variable: '{{user.managerEmail}}' } },
+ ]);
+ });
+
+ it('should treat embedded variables as plain text (not standalone)', () => {
+ const result = getMultiItemFieldEditorContent('hello {{user.email}}');
+
+ expect(result.content?.[0]?.content).toEqual([
+ { type: 'textTag', attrs: { text: 'hello {{user.email}}' } },
+ ]);
+ });
+
+ it('should skip empty entries from extra commas or whitespace', () => {
+ const result = getMultiItemFieldEditorContent(
+ 'first@example.com, , , second@example.com, ',
+ );
+
+ expect(result.content?.[0]?.content).toHaveLength(2);
+ });
+
+ it('should roundtrip correctly with parseMultiItemEditorContent', () => {
+ const original =
+ '{{trigger.record.email}}, static@example.com, {{user.email}}';
+ const content = getMultiItemFieldEditorContent(original);
+ const serialized = parseMultiItemEditorContent(content);
+
+ expect(serialized).toBe(original);
+ });
+});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/parseMultiItemEditorContent.test.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/parseMultiItemEditorContent.test.ts
new file mode 100644
index 0000000000..b7a92cdc35
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/__tests__/parseMultiItemEditorContent.test.ts
@@ -0,0 +1,52 @@
+import type { JSONContent } from '@tiptap/react';
+
+import { parseMultiItemEditorContent } from '@/workflow/workflow-variables/utils/parseMultiItemEditorContent';
+
+describe('parseMultiItemEditorContent', () => {
+ it('should serialize textTags and variableTags as comma-separated string', () => {
+ const input: JSONContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'textTag', attrs: { text: 'static@example.com' } },
+ { type: 'variableTag', attrs: { variable: '{{user.email}}' } },
+ { type: 'textTag', attrs: { text: 'another@example.com' } },
+ ],
+ },
+ ],
+ };
+
+ expect(parseMultiItemEditorContent(input)).toBe(
+ 'static@example.com, {{user.email}}, another@example.com',
+ );
+ });
+
+ it('should skip empty tags and ignore non-tag nodes', () => {
+ const input: JSONContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'textTag', attrs: { text: 'valid@example.com' } },
+ { type: 'textTag', attrs: { text: '' } },
+ { type: 'variableTag', attrs: {} },
+ { type: 'text', text: 'ignored plain text' },
+ { type: 'textTag', attrs: { text: 'another@example.com' } },
+ ],
+ },
+ ],
+ };
+
+ expect(parseMultiItemEditorContent(input)).toBe(
+ 'valid@example.com, another@example.com',
+ );
+ });
+
+ it('should return empty string for empty or missing content', () => {
+ expect(parseMultiItemEditorContent({ type: 'doc' })).toBe('');
+ expect(parseMultiItemEditorContent({ type: 'doc', content: [] })).toBe('');
+ });
+});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getMultiItemFieldEditorContent.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getMultiItemFieldEditorContent.ts
new file mode 100644
index 0000000000..f025e55332
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getMultiItemFieldEditorContent.ts
@@ -0,0 +1,40 @@
+import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
+import type { JSONContent } from '@tiptap/react';
+
+export const getMultiItemFieldEditorContent = (
+ rawContent: string,
+): JSONContent => {
+ const paragraphContent: JSONContent[] = [];
+
+ const parts = rawContent.split(/,\s*/);
+
+ parts.forEach((part) => {
+ const trimmedPart = part.trim();
+
+ if (trimmedPart.length === 0) {
+ return;
+ }
+
+ if (isStandaloneVariableString(trimmedPart)) {
+ paragraphContent.push({
+ type: 'variableTag',
+ attrs: { variable: trimmedPart },
+ });
+ } else {
+ paragraphContent.push({
+ type: 'textTag',
+ attrs: { text: trimmedPart },
+ });
+ }
+ });
+
+ return {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: paragraphContent,
+ },
+ ],
+ };
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/parseEditorContent.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/parseEditorContent.ts
index 05884fa397..19f0f89242 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/parseEditorContent.ts
+++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/parseEditorContent.ts
@@ -23,6 +23,10 @@ export const parseEditorContent = (json: JSONContent): string => {
return node.attrs?.variable || '';
}
+ if (node.type === 'textTag') {
+ return node.attrs?.text || '';
+ }
+
return '';
};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/parseMultiItemEditorContent.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/parseMultiItemEditorContent.ts
new file mode 100644
index 0000000000..43e9bab310
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/parseMultiItemEditorContent.ts
@@ -0,0 +1,44 @@
+import type { JSONContent } from '@tiptap/react';
+import { isDefined } from 'twenty-shared/utils';
+
+const isTagNode = (node: JSONContent): boolean => {
+ return node.type === 'textTag' || node.type === 'variableTag';
+};
+
+const getTagValue = (node: JSONContent): string => {
+ if (node.type === 'textTag') {
+ return node.attrs?.text || '';
+ }
+ if (node.type === 'variableTag') {
+ return node.attrs?.variable || '';
+ }
+ return '';
+};
+
+export const parseMultiItemEditorContent = (json: JSONContent): string => {
+ const collectTags = (nodes: JSONContent[]): string[] => {
+ const results: string[] = [];
+
+ for (const node of nodes) {
+ if (isTagNode(node)) {
+ const value = getTagValue(node);
+ if (value.length > 0) {
+ results.push(value);
+ }
+ } else if (
+ (node.type === 'paragraph' || node.type === 'doc') &&
+ isDefined(node.content)
+ ) {
+ results.push(...collectTags(node.content));
+ }
+ }
+
+ return results;
+ };
+
+ if (isDefined(json.content)) {
+ return collectTags(json.content).join(', ');
+ }
+
+ return '';
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/textTag.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/textTag.ts
new file mode 100644
index 0000000000..c85e2dc973
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/textTag.ts
@@ -0,0 +1,64 @@
+import { WorkflowTextEditorTextChip } from '@/workflow/workflow-variables/components/WorkflowTextEditorTextChip';
+import { Node } from '@tiptap/core';
+import { mergeAttributes, ReactNodeViewRenderer } from '@tiptap/react';
+
+declare module '@tiptap/core' {
+ interface Commands {
+ textTag: {
+ insertTextTag: (text: string) => ReturnType;
+ };
+ }
+}
+
+export const TextTag = Node.create({
+ name: 'textTag',
+ group: 'inline',
+ inline: true,
+ atom: true,
+
+ addAttributes: () => ({
+ text: {
+ default: null,
+ parseHTML: (element) => element.getAttribute('data-text'),
+ renderHTML: (attributes) => {
+ return {
+ 'data-text': attributes.text,
+ };
+ },
+ },
+ }),
+
+ renderHTML: ({ node, HTMLAttributes }) => {
+ const text = node.attrs.text as string;
+
+ return [
+ 'span',
+ mergeAttributes(HTMLAttributes, {
+ 'data-type': 'textTag',
+ class: 'text-tag',
+ }),
+ text,
+ ];
+ },
+
+ addNodeView: () => {
+ return ReactNodeViewRenderer(WorkflowTextEditorTextChip);
+ },
+
+ renderText: ({ node }) => {
+ return node.attrs.text;
+ },
+
+ addCommands: () => ({
+ insertTextTag:
+ (text: string) =>
+ ({ commands }) => {
+ commands.insertContent({
+ type: 'textTag',
+ attrs: { text },
+ });
+
+ return true;
+ },
+ }),
+});
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-migrate-send-email-recipients.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-migrate-send-email-recipients.command.ts
new file mode 100644
index 0000000000..aef554d5bc
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-migrate-send-email-recipients.command.ts
@@ -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,
+ protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
+ protected readonly dataSourceService: DataSourceService,
+ ) {
+ super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
+ }
+
+ override async runOnWorkspace({
+ workspaceId,
+ options,
+ }: RunOnWorkspaceArgs): Promise {
+ 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}`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module.ts
index 13ac5c1320..dcdbf3ca49 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module.ts
@@ -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 {}
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/utils/__tests__/migrate-send-email-step.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/utils/__tests__/migrate-send-email-step.util.spec.ts
new file mode 100644
index 0000000000..c4ccdc6c5a
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/utils/__tests__/migrate-send-email-step.util.spec.ts
@@ -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);
+ });
+});
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/utils/migrate-send-email-step.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/utils/migrate-send-email-step.util.ts
new file mode 100644
index 0000000000..22ea813a42
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/utils/migrate-send-email-step.util.ts
@@ -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 };
+};
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/__tests__/send-email-tool.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/__tests__/send-email-tool.spec.ts
new file mode 100644
index 0000000000..0b8d806121
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/__tests__/send-email-tool.spec.ts
@@ -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([]);
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema.ts
index 9424fc36f7..0cddc04458 100644
--- a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema.ts
@@ -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
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts
index b7505cdff1..49c9d97bb2 100644
--- a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts
@@ -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 {
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',
};
}
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util.ts
new file mode 100644
index 0000000000..6913965b05
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util.ts
@@ -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);
+};
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts
index 6d825bce1e..3bbd770e98 100644
--- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts
@@ -15,18 +15,23 @@ import {
import { ImapClientProvider } from 'src/modules/messaging/message-import-manager/drivers/imap/providers/imap-client.provider';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
+import { toMicrosoftRecipients } from 'src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util';
-interface SendMessageInput {
+type EmailAddress = string | string[];
+
+type SendMessageInput = {
body: string;
subject: string;
- to: string;
+ to: EmailAddress;
+ cc?: EmailAddress;
+ bcc?: EmailAddress;
html: string;
attachments?: {
filename: string;
content: Buffer;
contentType: string;
}[];
-}
+};
@Injectable()
export class MessagingSendMessageService {
@@ -75,6 +80,8 @@ export class MessagingSendMessageService {
? `"${mimeEncode(fromName)}" <${fromEmail}>`
: `${fromEmail}`,
to: sendMessageInput.to,
+ cc: sendMessageInput.cc,
+ bcc: sendMessageInput.bcc,
subject: sendMessageInput.subject,
text: sendMessageInput.body,
html: sendMessageInput.html,
@@ -90,7 +97,11 @@ export class MessagingSendMessageService {
: {}),
});
- const messageBuffer = await mail.compile().build();
+ const compiledMessage = mail.compile();
+
+ compiledMessage.keepBcc = true;
+
+ const messageBuffer = await compiledMessage.build();
const encodedMessage = Buffer.from(messageBuffer).toString('base64');
await gmailClient.users.messages.send({
@@ -113,7 +124,9 @@ export class MessagingSendMessageService {
contentType: 'HTML',
content: sendMessageInput.html,
},
- toRecipients: [{ emailAddress: { address: sendMessageInput.to } }],
+ toRecipients: toMicrosoftRecipients(sendMessageInput.to),
+ ccRecipients: toMicrosoftRecipients(sendMessageInput.cc),
+ bccRecipients: toMicrosoftRecipients(sendMessageInput.bcc),
...(sendMessageInput.attachments &&
sendMessageInput.attachments.length > 0
? {
@@ -154,6 +167,8 @@ export class MessagingSendMessageService {
const mail = new MailComposer({
from: handle,
to: sendMessageInput.to,
+ cc: sendMessageInput.cc,
+ bcc: sendMessageInput.bcc,
subject: sendMessageInput.subject,
text: sendMessageInput.body,
html: sendMessageInput.html,
@@ -174,6 +189,8 @@ export class MessagingSendMessageService {
await smtpClient.sendMail({
from: handle,
to: sendMessageInput.to,
+ cc: sendMessageInput.cc,
+ bcc: sendMessageInput.bcc,
raw: messageBuffer,
});
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/to-microsoft-recipients.util.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/to-microsoft-recipients.util.spec.ts
new file mode 100644
index 0000000000..098f1fc925
--- /dev/null
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/to-microsoft-recipients.util.spec.ts
@@ -0,0 +1,39 @@
+import { toMicrosoftRecipients } from 'src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util';
+
+describe('toMicrosoftRecipients', () => {
+ it('should return empty array when addresses is undefined', () => {
+ expect(toMicrosoftRecipients(undefined)).toEqual([]);
+ });
+
+ it('should convert a single email string to Microsoft recipient format', () => {
+ const result = toMicrosoftRecipients('john@example.com');
+
+ expect(result).toEqual([{ emailAddress: { address: 'john@example.com' } }]);
+ });
+
+ it('should convert an array of emails to Microsoft recipient format', () => {
+ const result = toMicrosoftRecipients([
+ 'john@example.com',
+ 'jane@example.com',
+ ]);
+
+ expect(result).toEqual([
+ { emailAddress: { address: 'john@example.com' } },
+ { emailAddress: { address: 'jane@example.com' } },
+ ]);
+ });
+
+ it('should return empty array for empty string array', () => {
+ expect(toMicrosoftRecipients([])).toEqual([]);
+ });
+
+ it('should preserve email addresses exactly as provided', () => {
+ const emailWithPlus = 'user+tag@example.com';
+ const emailWithDots = 'first.last@sub.domain.com';
+
+ const result = toMicrosoftRecipients([emailWithPlus, emailWithDots]);
+
+ expect(result[0].emailAddress.address).toBe(emailWithPlus);
+ expect(result[1].emailAddress.address).toBe(emailWithDots);
+ });
+});
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util.ts
new file mode 100644
index 0000000000..9e20c15095
--- /dev/null
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/to-microsoft-recipients.util.ts
@@ -0,0 +1,19 @@
+type EmailAddress = string | string[];
+
+type MicrosoftRecipient = {
+ emailAddress: {
+ address: string;
+ };
+};
+
+export const toMicrosoftRecipients = (
+ addresses: EmailAddress | undefined,
+): MicrosoftRecipient[] => {
+ if (!addresses) return [];
+
+ const addressArray = Array.isArray(addresses) ? addresses : [addresses];
+
+ return addressArray.map((address) => ({
+ emailAddress: { address },
+ }));
+};
diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/utils/__tests__/compute-workflow-version-step-changes.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/utils/__tests__/compute-workflow-version-step-changes.util.spec.ts
index e5f339395c..bf59127b80 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-builder/utils/__tests__/compute-workflow-version-step-changes.util.spec.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-builder/utils/__tests__/compute-workflow-version-step-changes.util.spec.ts
@@ -41,7 +41,11 @@ describe('computeWorkflowVersionStepChanges', () => {
settings: {
input: {
connectedAccountId: '',
- email: '',
+ recipients: {
+ to: '',
+ cc: '',
+ bcc: '',
+ },
subject: '',
body: '',
},
diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts
index 32dfa60b3f..de8250ae6a 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts
@@ -242,7 +242,11 @@ export class WorkflowVersionStepOperationsWorkspaceService {
...BASE_STEP_DEFINITION,
input: {
connectedAccountId: '',
- email: '',
+ recipients: {
+ to: '',
+ cc: '',
+ bcc: '',
+ },
subject: '',
body: '',
},
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-input.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-input.type.ts
index 495cdaeb9d..4e732f743f 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-input.type.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-input.type.ts
@@ -1,6 +1,8 @@
+import { type EmailRecipients } from 'twenty-shared/workflow';
+
export type WorkflowSendEmailActionInput = {
connectedAccountId: string;
- email: string;
+ recipients: EmailRecipients;
subject?: string;
body?: string;
};
diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts
index ae7690e3bb..69e829f558 100644
--- a/packages/twenty-shared/src/workflow/index.ts
+++ b/packages/twenty-shared/src/workflow/index.ts
@@ -65,6 +65,7 @@ export { workflowRunStateStepInfosSchema } from './schemas/workflow-run-state-st
export { workflowRunStatusSchema } from './schemas/workflow-run-status-schema';
export { workflowRunStepStatusSchema } from './schemas/workflow-run-step-status-schema';
export { workflowTriggerSchema } from './schemas/workflow-trigger-schema';
+export type { EmailRecipients } from './types/EmailRecipients';
export type { StepIfElseBranch } from './types/StepIfElseBranch';
export type { BodyType } from './types/workflowHttpRequestStep';
export type {
diff --git a/packages/twenty-shared/src/workflow/schemas/send-email-action-settings-schema.ts b/packages/twenty-shared/src/workflow/schemas/send-email-action-settings-schema.ts
index b7a0378d43..2970183154 100644
--- a/packages/twenty-shared/src/workflow/schemas/send-email-action-settings-schema.ts
+++ b/packages/twenty-shared/src/workflow/schemas/send-email-action-settings-schema.ts
@@ -6,7 +6,11 @@ export const workflowSendEmailActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({
input: z.object({
connectedAccountId: z.string(),
- email: z.string(),
+ recipients: z.object({
+ to: z.string().optional().default(''),
+ cc: z.string().optional().default(''),
+ bcc: z.string().optional().default(''),
+ }),
subject: z.string().optional(),
body: z.string().optional(),
files: z.array(workflowFileSchema).optional().default([]),
diff --git a/packages/twenty-shared/src/workflow/types/EmailRecipients.ts b/packages/twenty-shared/src/workflow/types/EmailRecipients.ts
new file mode 100644
index 0000000000..68123aae69
--- /dev/null
+++ b/packages/twenty-shared/src/workflow/types/EmailRecipients.ts
@@ -0,0 +1,5 @@
+export type EmailRecipients = {
+ to: string;
+ cc?: string;
+ bcc?: string;
+};