Workflow Send Email Node Multiple Recipients Support (#17458)

This PR adds support sending emails to multiple recipients

Figma Reference:

https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=88868-88963&t=Ya0csmNlN4xxczvV-11

Demo:


https://github.com/user-attachments/assets/ecaeaaec-fe42-4fb5-96d3-a91d08b30148
This commit is contained in:
neo773
2026-01-30 21:24:21 +05:30
committed by GitHub
parent 5791bd2943
commit cc1ca630fd
32 changed files with 1371 additions and 137 deletions
@@ -0,0 +1,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 (
<StyledChip deletable={isDeletable} danger={danger}>
{leftIcon}
<StyledLabel title={title ?? label} danger={danger}>
{label}
</StyledLabel>
{isDeletable && (
<StyledDelete
type="button"
onClick={onRemove}
aria-label={removeAriaLabel}
danger={danger}
>
<IconX size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
</StyledDelete>
)}
</StyledChip>
);
};
@@ -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%;
`;
@@ -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 (
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer multiline={false}>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
multiline={false}
>
<TextVariableEditor
editor={editor}
multiline={false}
readonly={readonly}
/>
</FormFieldInputInnerContainer>
{VariablePicker && !readonly ? (
<VariablePicker
instanceId={instanceId}
multiline={false}
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
};
@@ -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 {
@@ -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 (
<StyledChip deletable={isDefined(onRemove)} danger={isVariableNotFound}>
{!isDefined(variableLabel) && (
<IconAlertTriangle
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={theme.color.red}
/>
)}
<StyledLabel title={title} danger={isVariableNotFound}>
{label}
</StyledLabel>
{onRemove ? (
<StyledDelete
onClick={onRemove}
aria-label={t`Remove variable`}
danger={isVariableNotFound}
>
<IconX size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
</StyledDelete>
) : null}
</StyledChip>
<BaseChip
label={label}
title={title}
onRemove={onRemove}
removeAriaLabel={t`Remove variable`}
danger={isVariableNotFound}
leftIcon={
isVariableNotFound ? (
<IconAlertTriangle
size={theme.icon.size.sm}
stroke={theme.icon.stroke.sm}
color={theme.color.red}
/>
) : undefined
}
/>
);
};
@@ -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;
};
@@ -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<EmailRecipients>;
subject: string;
body: string;
files: WorkflowFile[];
@@ -82,14 +89,41 @@ export const WorkflowEditActionSendEmail = ({
const redirectUrl = `/object/workflow/${workflowVisualizerWorkflowId}`;
const [formData, setFormData] = useState<SendEmailFormData>({
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<SendEmailFormData>(() => {
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}
/>
<FormTextFieldInput
label={t`Email`}
placeholder={t`Enter receiver email`}
<FormMultiTextFieldInput
label={t`To`}
placeholder={t`Enter emails, comma-separated`}
readonly={actionOptions.readonly}
defaultValue={formData.email}
onChange={(email) => {
handleFieldChange('email', email);
defaultValue={formData.recipients.to}
onChange={(value) => {
handleFieldChange('recipients', {
...formData.recipients,
to: value,
});
}}
VariablePicker={WorkflowVariablePicker}
/>
{visibleAdvancedFields.cc && (
<FormMultiTextFieldInput
label={t`CC`}
placeholder={t`Enter CC emails, comma-separated`}
readonly={actionOptions.readonly}
defaultValue={formData.recipients.cc}
onChange={(value) => {
handleFieldChange('recipients', {
...formData.recipients,
cc: value,
});
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
{visibleAdvancedFields.bcc && (
<FormMultiTextFieldInput
label={t`BCC`}
placeholder={t`Enter BCC emails, comma-separated`}
readonly={actionOptions.readonly}
defaultValue={formData.recipients.bcc}
onChange={(value) => {
handleFieldChange('recipients', {
...formData.recipients,
bcc: value,
});
}}
VariablePicker={WorkflowVariablePicker}
/>
)}
{!actionOptions.readonly && hasAvailableAdvancedOptions && (
<Dropdown
dropdownId={advancedOptionsDropdownId}
dropdownPlacement="bottom-start"
clickableComponent={
<Button
title={t`Advanced options`}
variant="secondary"
accent="default"
size="small"
/>
}
dropdownComponents={
<DropdownContent
widthInPixels={GenericDropdownContentWidth.Medium}
>
<DropdownMenuItemsContainer>
{!visibleAdvancedFields.cc && (
<MenuItem
text={t`Add CC`}
onClick={() => {
setVisibleAdvancedFields((prev) => ({
...prev,
cc: true,
}));
closeDropdown(advancedOptionsDropdownId);
}}
/>
)}
{!visibleAdvancedFields.bcc && (
<MenuItem
text={t`Add BCC`}
onClick={() => {
setVisibleAdvancedFields((prev) => ({
...prev,
bcc: true,
}));
closeDropdown(advancedOptionsDropdownId);
}}
/>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
)}
<FormTextFieldInput
label={t`Subject`}
placeholder={t`Enter email subject`}
@@ -24,7 +24,11 @@ const DEFAULT_ACTION: WorkflowSendEmailAction = {
settings: {
input: {
connectedAccountId: '',
email: '',
recipients: {
to: '',
cc: '',
bcc: '',
},
subject: '',
body: '',
files: [],
@@ -49,7 +53,11 @@ const CONFIGURED_ACTION: WorkflowSendEmailAction = {
settings: {
input: {
connectedAccountId: mockedConnectedAccounts[0].accountOwnerId,
email: 'test@twenty.com',
recipients: {
to: 'test@twenty.com',
cc: '',
bcc: '',
},
subject: 'Welcome to Twenty!',
body: 'Dear Tim,\n\nWelcome to Twenty! We are excited to have you on board.\n\nBest regards,\nThe Team',
files: [],
@@ -111,8 +119,10 @@ export const Default: Story = {
const canvas = within(canvasElement);
expect(await canvas.findByText('Account')).toBeVisible();
expect(await canvas.findByText('To')).toBeVisible();
expect(await canvas.findByText('Subject')).toBeVisible();
expect(await canvas.findByText('Body')).toBeVisible();
expect(await canvas.findByText('Advanced options')).toBeVisible();
},
};
@@ -127,8 +137,7 @@ export const Configured: Story = {
const canvas = within(canvasElement);
expect(await canvas.findByText('Account')).toBeVisible();
expect(await canvas.findByText('Subject')).toBeVisible();
expect(await canvas.findByText('Body')).toBeVisible();
expect(await canvas.findByText('To')).toBeVisible();
const emailInput = await canvas.findByText('tim@twenty.com');
expect(emailInput).toBeVisible();
@@ -0,0 +1,27 @@
import { BaseChip } from '@/object-record/record-field/ui/form-types/components/BaseChip';
import styled from '@emotion/styled';
import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react';
const StyledWrapper = styled.span`
display: inline-block;
padding-inline: ${({ theme }) => theme.spacing(0.5)};
`;
type WorkflowTextEditorTextChipProps = NodeViewProps;
export const WorkflowTextEditorTextChip = ({
deleteNode,
node,
editor,
}: WorkflowTextEditorTextChipProps) => {
const text = node.attrs.text as string;
return (
<NodeViewWrapper as={StyledWrapper} style={{ whiteSpace: 'nowrap' }}>
<BaseChip
label={text}
onRemove={editor.isEditable ? deleteNode : undefined}
/>
</NodeViewWrapper>
);
};
@@ -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);
});
});
@@ -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('');
});
});
@@ -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,
},
],
};
};
@@ -23,6 +23,10 @@ export const parseEditorContent = (json: JSONContent): string => {
return node.attrs?.variable || '';
}
if (node.type === 'textTag') {
return node.attrs?.text || '';
}
return '';
};
@@ -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 '';
};
@@ -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<ReturnType> {
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;
},
}),
});
@@ -0,0 +1,85 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { migrateWorkflowSteps } from 'src/database/commands/upgrade-version-command/1-17/utils/migrate-send-email-step.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@Command({
name: 'upgrade:1-17:migrate-send-email-recipients',
description:
'Migrate send email action from legacy email field to recipients object',
})
export class MigrateSendEmailRecipientsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
protected readonly logger = new Logger(
MigrateSendEmailRecipientsCommand.name,
);
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
) {
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`Running MigrateSendEmailRecipientsCommand for workspace ${workspaceId}`,
);
const workflowVersionRepository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const workflowVersions = await workflowVersionRepository.find({
select: ['id', 'steps'],
});
let migratedCount = 0;
for (const version of workflowVersions) {
const { migratedSteps, hasChanges } = migrateWorkflowSteps(version.steps);
if (!hasChanges) {
continue;
}
migratedCount++;
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would migrate workflow version ${version.id} in workspace ${workspaceId}`,
);
} else {
await workflowVersionRepository.update(
{ id: version.id },
{ steps: migratedSteps },
);
this.logger.log(
`Migrated workflow version ${version.id} in workspace ${workspaceId}`,
);
}
}
this.logger.log(
`${isDryRun ? '[DRY RUN] Would have migrated' : 'Migrated'} ${migratedCount} workflow version(s) in workspace ${workspaceId}`,
);
}
}
@@ -5,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 {}
@@ -0,0 +1,98 @@
import {
migrateInput,
migrateWorkflowSteps,
needsMigration,
} from 'src/database/commands/upgrade-version-command/1-17/utils/migrate-send-email-step.util';
const LEGACY_STEP_FROM_PRODUCTION = {
id: '3b8934cd-1dda-4acb-a050-785e04f7f40b',
name: 'Send Email',
type: 'SEND_EMAIL',
valid: false,
position: { x: 0, y: 150 },
settings: {
input: {
body: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"sample"}]}]}',
email: 'sample@gmail.com',
files: [],
subject: 'sample',
connectedAccountId: '',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
describe('needsMigration', () => {
it('returns true for legacy email field', () => {
expect(
needsMigration({ connectedAccountId: '', email: 'test@example.com' }),
).toBe(true);
});
it('returns false when recipients is already used', () => {
expect(
needsMigration({
connectedAccountId: '',
recipients: { to: 'test@example.com' },
}),
).toBe(false);
});
});
describe('migrateInput', () => {
it('converts legacy email to recipients.to', () => {
const result = migrateInput({
connectedAccountId: 'acc-123',
email: 'legacy@example.com',
subject: 'Test',
body: 'Body',
});
expect(result.recipients.to).toBe('legacy@example.com');
expect(result).not.toHaveProperty('email');
});
});
describe('migrateWorkflowSteps', () => {
it('migrates real production workflow with legacy email field', () => {
const { migratedSteps, hasChanges } = migrateWorkflowSteps([
LEGACY_STEP_FROM_PRODUCTION,
]);
expect(hasChanges).toBe(true);
expect(migratedSteps[0].settings.input).toEqual({
body: '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"sample"}]}]}',
files: [],
subject: 'sample',
connectedAccountId: '',
recipients: {
to: 'sample@gmail.com',
cc: '',
bcc: '',
},
});
});
it('returns hasChanges false when no migration needed', () => {
const alreadyMigratedStep = {
...LEGACY_STEP_FROM_PRODUCTION,
settings: {
...LEGACY_STEP_FROM_PRODUCTION.settings,
input: {
connectedAccountId: '',
recipients: { to: 'new@example.com', cc: '', bcc: '' },
subject: 'Test',
body: 'Body',
},
},
};
const { hasChanges } = migrateWorkflowSteps([alreadyMigratedStep]);
expect(hasChanges).toBe(false);
});
});
@@ -0,0 +1,95 @@
import { isDefined } from 'twenty-shared/utils';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
type LegacySendEmailInput = {
connectedAccountId: string;
email?: string;
recipients?: {
to?: string;
cc?: string;
bcc?: string;
};
subject?: string;
body?: string;
files?: unknown[];
};
type MigratedSendEmailInput = {
connectedAccountId: string;
recipients: {
to: string;
cc: string;
bcc: string;
};
subject?: string;
body?: string;
files?: unknown[];
};
type WorkflowStep = {
id: string;
type: string;
settings: {
input: LegacySendEmailInput | MigratedSendEmailInput;
};
};
export const needsMigration = (input: LegacySendEmailInput): boolean => {
return isDefined(input.email);
};
export const migrateInput = (
input: LegacySendEmailInput,
): MigratedSendEmailInput => {
const { email, recipients, ...rest } = input;
const toValue = recipients?.to || email || '';
const ccValue = recipients?.cc ?? '';
const bccValue = recipients?.bcc ?? '';
return {
...rest,
recipients: {
to: toValue,
cc: ccValue,
bcc: bccValue,
},
};
};
export const migrateWorkflowSteps = (
steps: unknown,
): { migratedSteps: WorkflowStep[]; hasChanges: boolean } => {
if (!isDefined(steps) || !Array.isArray(steps) || steps.length === 0) {
return { migratedSteps: [], hasChanges: false };
}
const typedSteps = steps as WorkflowStep[];
let hasChanges = false;
const migratedSteps = typedSteps.map((step) => {
if (step.type !== WorkflowActionType.SEND_EMAIL) {
return step;
}
const input = step.settings.input as LegacySendEmailInput;
if (!needsMigration(input)) {
return step;
}
hasChanges = true;
return {
...step,
settings: {
...step.settings,
input: migrateInput(input),
},
};
});
return { migratedSteps, hasChanges };
};
@@ -0,0 +1,33 @@
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util';
describe('SendEmailTool - parseCommaSeparatedEmails', () => {
it('should parse comma-separated emails into array', () => {
expect(
parseCommaSeparatedEmails('a@test.com, b@test.com, c@test.com'),
).toEqual(['a@test.com', 'b@test.com', 'c@test.com']);
});
it('should handle workflow variables mixed with emails', () => {
expect(
parseCommaSeparatedEmails(
'{{trigger.record.email}}, static@example.com, {{user.email}}',
),
).toEqual([
'{{trigger.record.email}}',
'static@example.com',
'{{user.email}}',
]);
});
it('should filter out empty entries and handle irregular spacing', () => {
expect(
parseCommaSeparatedEmails(' a@test.com , , b@test.com, '),
).toEqual(['a@test.com', 'b@test.com']);
});
it('should return empty array for undefined or empty input', () => {
expect(parseCommaSeparatedEmails(undefined)).toEqual([]);
expect(parseCommaSeparatedEmails('')).toEqual([]);
expect(parseCommaSeparatedEmails(' ')).toEqual([]);
});
});
@@ -2,8 +2,27 @@ import { isValidUuid } from 'twenty-shared/utils';
import { workflowFileSchema } from 'twenty-shared/workflow';
import { z } from 'zod';
const EmailRecipientsZodSchema = z.object({
to: z
.string()
.describe('Comma-separated recipient email addresses (To)')
.default(''),
cc: z
.string()
.describe('Comma-separated CC email addresses')
.optional()
.default(''),
bcc: z
.string()
.describe('Comma-separated BCC email addresses')
.optional()
.default(''),
});
export const SendEmailInputZodSchema = z.object({
email: z.email().describe('The recipient email address'),
recipients: EmailRecipientsZodSchema.describe(
'Recipients object with to, cc, and bcc fields (comma-separated)',
),
subject: z.string().describe('The email subject line'),
body: z.string().describe('The email body content (HTML or plain text)'),
connectedAccountId: z
@@ -20,6 +20,7 @@ import {
} from 'src/engine/core-modules/tool/tools/send-email-tool/exceptions/send-email-tool.exception';
import { SendEmailInputZodSchema } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema';
import { type SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/send-email-tool/utils/parse-comma-separated-emails.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
type Tool,
@@ -121,6 +122,59 @@ export class SendEmailTool implements Tool {
);
}
private normalizeRecipients(parameters: SendEmailInput): {
to: string[];
cc: string[];
bcc: string[];
} {
if (
!parameters.recipients ||
!parameters.recipients.to ||
parameters.recipients.to.trim().length === 0
) {
throw new SendEmailToolException(
'No recipients specified',
SendEmailToolExceptionCode.INVALID_EMAIL,
);
}
const to = parseCommaSeparatedEmails(parameters.recipients.to);
if (to.length === 0) {
throw new SendEmailToolException(
'No valid recipients specified',
SendEmailToolExceptionCode.INVALID_EMAIL,
);
}
return {
to,
cc: parseCommaSeparatedEmails(parameters.recipients.cc),
bcc: parseCommaSeparatedEmails(parameters.recipients.bcc),
};
}
private validateEmails(recipients: {
to: string[];
cc: string[];
bcc: string[];
}): string[] {
const emailSchema = z.string().trim().pipe(z.email());
const invalidEmails: string[] = [];
const allEmails = [...recipients.to, ...recipients.cc, ...recipients.bcc];
for (const email of allEmails) {
const result = emailSchema.safeParse(email);
if (!result.success) {
invalidEmails.push(email);
}
}
return invalidEmails;
}
private async getAttachments(
files: Array<{ id: string; name: string; type: string }>,
workspaceId: string,
@@ -186,23 +240,35 @@ export class SendEmailTool implements Tool {
context: ToolExecutionContext,
): Promise<ToolOutput> {
const { workspaceId } = context;
const { email, subject, body, files } = parameters;
const { subject, body, files } = parameters;
let { connectedAccountId } = parameters;
let recipients: { to: string[]; cc: string[]; bcc: string[] };
try {
const emailSchema = z
.string()
.trim()
.pipe(z.email({ error: 'Invalid email' }));
const emailValidation = emailSchema.safeParse(email);
recipients = this.normalizeRecipients(parameters);
} catch (error) {
return {
success: false,
message: 'No recipients specified',
error:
error instanceof Error ? error.message : 'No recipients specified',
};
}
if (!emailValidation.success) {
throw new SendEmailToolException(
`Email '${email}' is invalid`,
SendEmailToolExceptionCode.INVALID_EMAIL,
);
}
const invalidEmails = this.validateEmails(recipients);
if (invalidEmails.length > 0) {
return {
success: false,
message: `Invalid email addresses: ${invalidEmails.join(', ')}`,
error: `Invalid email addresses: ${invalidEmails.join(', ')}`,
};
}
const toRecipientsDisplay = recipients.to.join(', ');
try {
if (!connectedAccountId) {
connectedAccountId =
await this.getOrThrowFirstConnectedAccountId(workspaceId);
@@ -213,16 +279,23 @@ export class SendEmailTool implements Tool {
workspaceId,
);
const messageChannelId = connectedAccount.messageChannels.find(
const messageChannel = connectedAccount.messageChannels.find(
(channel) => channel.handle === connectedAccount.handle,
)?.id!;
);
if (!isDefined(messageChannel)) {
throw new SendEmailToolException(
`No message channel found for connected account '${connectedAccountId}'`,
SendEmailToolExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND,
);
}
const { accessToken, refreshToken } =
await this.messagingAccountAuthenticationService.validateAndRefreshConnectedAccountAuthentication(
{
connectedAccount,
workspaceId,
messageChannelId,
messageChannelId: messageChannel.id,
},
);
@@ -247,7 +320,9 @@ export class SendEmailTool implements Tool {
await this.sendMessageService.sendMessage(
{
to: email,
to: recipients.to,
cc: recipients.cc.length > 0 ? recipients.cc : undefined,
bcc: recipients.bcc.length > 0 ? recipients.bcc : undefined,
subject: safeSubject,
body: textBody,
html: safeHtmlBody,
@@ -257,14 +332,16 @@ export class SendEmailTool implements Tool {
);
this.logger.log(
`Email sent successfully to ${email}${attachments.length > 0 ? ` with ${attachments.length} attachments` : ''}`,
`Email sent successfully to ${toRecipientsDisplay}${attachments.length > 0 ? ` with ${attachments.length} attachments` : ''}`,
);
return {
success: true,
message: `Email sent successfully to ${email}`,
message: `Email sent successfully to ${toRecipientsDisplay}`,
result: {
recipient: email,
recipients: recipients.to,
ccRecipients: recipients.cc,
bccRecipients: recipients.bcc,
subject: safeSubject,
connectedAccountId,
attachmentCount: attachments.length,
@@ -274,7 +351,7 @@ export class SendEmailTool implements Tool {
if (error instanceof SendEmailToolException) {
return {
success: false,
message: `Failed to send email to ${email}`,
message: `Failed to send email to ${toRecipientsDisplay}`,
error: error.message,
};
}
@@ -283,7 +360,7 @@ export class SendEmailTool implements Tool {
return {
success: false,
message: `Failed to send email to ${email}`,
message: `Failed to send email to ${toRecipientsDisplay}`,
error: error instanceof Error ? error.message : 'Failed to send email',
};
}
@@ -0,0 +1,14 @@
import { isDefined } from 'twenty-shared/utils';
export const parseCommaSeparatedEmails = (
value: string | undefined,
): string[] => {
if (!isDefined(value)) {
return [];
}
return value
.split(',')
.map((email) => email.trim())
.filter((email) => email.length > 0);
};
@@ -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,
});
@@ -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);
});
});
@@ -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 },
}));
};
@@ -41,7 +41,11 @@ describe('computeWorkflowVersionStepChanges', () => {
settings: {
input: {
connectedAccountId: '',
email: '',
recipients: {
to: '',
cc: '',
bcc: '',
},
subject: '',
body: '',
},
@@ -242,7 +242,11 @@ export class WorkflowVersionStepOperationsWorkspaceService {
...BASE_STEP_DEFINITION,
input: {
connectedAccountId: '',
email: '',
recipients: {
to: '',
cc: '',
bcc: '',
},
subject: '',
body: '',
},
@@ -1,6 +1,8 @@
import { type EmailRecipients } from 'twenty-shared/workflow';
export type WorkflowSendEmailActionInput = {
connectedAccountId: string;
email: string;
recipients: EmailRecipients;
subject?: string;
body?: string;
};
@@ -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 {
@@ -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([]),
@@ -0,0 +1,5 @@
export type EmailRecipients = {
to: string;
cc?: string;
bcc?: string;
};