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,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;
},
}),
});