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 && ( + { + setVisibleAdvancedFields((prev) => ({ + ...prev, + cc: true, + })); + closeDropdown(advancedOptionsDropdownId); + }} + /> + )} + {!visibleAdvancedFields.bcc && ( + { + setVisibleAdvancedFields((prev) => ({ + ...prev, + bcc: true, + })); + closeDropdown(advancedOptionsDropdownId); + }} + /> + )} + + + } + /> + )} 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; +};