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:
+104
@@ -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>
|
||||
);
|
||||
};
|
||||
+6
-1
@@ -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%;
|
||||
`;
|
||||
|
||||
|
||||
+85
@@ -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>
|
||||
);
|
||||
};
|
||||
+13
-1
@@ -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 {
|
||||
|
||||
+19
-85
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+157
@@ -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;
|
||||
};
|
||||
+128
-15
@@ -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`}
|
||||
|
||||
+13
-4
@@ -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();
|
||||
|
||||
+27
@@ -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>
|
||||
);
|
||||
};
|
||||
+63
@@ -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);
|
||||
});
|
||||
});
|
||||
+52
@@ -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('');
|
||||
});
|
||||
});
|
||||
+40
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
+4
@@ -23,6 +23,10 @@ export const parseEditorContent = (json: JSONContent): string => {
|
||||
return node.attrs?.variable || '';
|
||||
}
|
||||
|
||||
if (node.type === 'textTag') {
|
||||
return node.attrs?.text || '';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
|
||||
+44
@@ -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;
|
||||
},
|
||||
}),
|
||||
});
|
||||
+85
@@ -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
@@ -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 {}
|
||||
|
||||
+98
@@ -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);
|
||||
});
|
||||
});
|
||||
+95
@@ -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 };
|
||||
};
|
||||
+33
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+20
-1
@@ -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
|
||||
|
||||
+98
-21
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
+14
@@ -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);
|
||||
};
|
||||
+22
-5
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+39
@@ -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);
|
||||
});
|
||||
});
|
||||
+19
@@ -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 },
|
||||
}));
|
||||
};
|
||||
+5
-1
@@ -41,7 +41,11 @@ describe('computeWorkflowVersionStepChanges', () => {
|
||||
settings: {
|
||||
input: {
|
||||
connectedAccountId: '',
|
||||
email: '',
|
||||
recipients: {
|
||||
to: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
},
|
||||
subject: '',
|
||||
body: '',
|
||||
},
|
||||
|
||||
+5
-1
@@ -242,7 +242,11 @@ export class WorkflowVersionStepOperationsWorkspaceService {
|
||||
...BASE_STEP_DEFINITION,
|
||||
input: {
|
||||
connectedAccountId: '',
|
||||
email: '',
|
||||
recipients: {
|
||||
to: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
},
|
||||
subject: '',
|
||||
body: '',
|
||||
},
|
||||
|
||||
+3
-1
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user