feat: rich text email body (#14482)

Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Arik Chakma 2025-09-21 20:55:34 +06:00 committed by GitHub
parent a89e5d0f27
commit 9a2766feae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 3312 additions and 276 deletions

View file

@ -7,8 +7,8 @@
"@floating-ui/react": "^0.24.3",
"@linaria/core": "^6.2.0",
"@linaria/react": "^6.2.1",
"@react-email/components": "0.0.35",
"@react-email/render": "0.0.17",
"@react-email/components": "^0.5.3",
"@react-email/render": "^1.2.3",
"@sentry/profiling-node": "^9.26.0",
"@sentry/react": "^9.26.0",
"@sniptt/guards": "^0.2.0",

View file

@ -21,6 +21,7 @@
"@lingui/cli": "^5.1.2",
"@lingui/swc-plugin": "^5.6.0",
"@lingui/vite-plugin": "^5.1.2",
"@tiptap/core": "^3.4.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"react-email": "4.0.3"

View file

@ -1,7 +1,9 @@
export type { JSONContent } from '@tiptap/core';
export * from './emails/clean-suspended-workspace.email';
export * from './emails/password-reset-link.email';
export * from './emails/password-update-notify.email';
export * from './emails/send-email-verification-link.email';
export * from './emails/send-invite-link.email';
export * from './emails/warn-suspended-workspace.email';
export * from './emails/validate-approved-access-domain.email';
export * from './emails/warn-suspended-workspace.email';
export * from './utils/email-renderer/email-renderer';

View file

@ -0,0 +1,34 @@
import { Body, Container, Head, Html } from '@react-email/components';
import { type JSONContent } from '@tiptap/core';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
export const reactMarkupFromJSON = (json: JSONContent | string) => {
if (typeof json === 'string') {
return json;
}
const jsxNodes = mappedNodeContent(json);
return (
<Html>
<Head>
<style
dangerouslySetInnerHTML={{
__html: `blockquote,h1,h2,h3,img,li,ol,p,ul{margin-top:0;margin-bottom:0}`,
}}
/>
</Head>
<Body>
<Container
style={{
width: '100%',
marginLeft: 'auto',
marginRight: 'auto',
borderStyle: 'solid',
}}
>
{jsxNodes}
</Container>
</Body>
</Html>
);
};

View file

@ -0,0 +1,6 @@
import { type ReactNode } from 'react';
import { type TipTapMark } from 'twenty-shared/utils';
export const bold = (_: TipTapMark, children: ReactNode): ReactNode => {
return <strong>{children}</strong>;
};

View file

@ -0,0 +1,6 @@
import { type ReactNode } from 'react';
import { type TipTapMark } from 'twenty-shared/utils';
export const italic = (_: TipTapMark, children: ReactNode): ReactNode => {
return <em>{children}</em>;
};

View file

@ -0,0 +1,21 @@
import { type ReactNode } from 'react';
import { type LinkMarkAttributes, type TipTapMark } from 'twenty-shared/utils';
export const link = (mark: TipTapMark, children: ReactNode): ReactNode => {
const {
href,
target = '_blank',
rel = 'noopener noreferrer',
} = (mark.attrs as LinkMarkAttributes) || {};
return (
<a
href={href}
target={target}
rel={rel}
style={{ textDecoration: 'underline' }}
>
{children}
</a>
);
};

View file

@ -0,0 +1,6 @@
import { type ReactNode } from 'react';
import { type TipTapMark } from 'twenty-shared/utils';
export const strike = (_: TipTapMark, children: ReactNode): ReactNode => {
return <span style={{ textDecoration: 'line-through' }}>{children}</span>;
};

View file

@ -0,0 +1,6 @@
import { type ReactNode } from 'react';
import { type TipTapMark } from 'twenty-shared/utils';
export const underline = (_: TipTapMark, children: ReactNode): ReactNode => {
return <span style={{ textDecoration: 'underline' }}>{children}</span>;
};

View file

@ -0,0 +1,35 @@
import { Heading } from '@react-email/components';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
import { isDefined } from 'twenty-shared/utils';
type HeadingLevel = 1 | 2 | 3;
type HeadingStyle = {
element: 'h1' | 'h2' | 'h3';
fontSize: string;
};
const HEADING_STYLES: Record<HeadingLevel, HeadingStyle> = {
1: { element: 'h1', fontSize: '36px' },
2: { element: 'h2', fontSize: '30px' },
3: { element: 'h3', fontSize: '24px' },
};
export const heading = (node: JSONContent): ReactNode => {
const { level } = node?.attrs || {};
if (!isDefined(level) || !HEADING_STYLES[level as HeadingLevel]) {
return null;
}
const content = mappedNodeContent(node);
const { element, fontSize } = HEADING_STYLES[level as HeadingLevel];
return (
<Heading as={element} style={{ fontSize }}>
{content}
</Heading>
);
};

View file

@ -0,0 +1,31 @@
import { Column, Row } from '@react-email/components';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const image = (node: JSONContent): ReactNode => {
const { src, alt, align = 'left', width } = node?.attrs || {};
if (!isDefined(src)) {
return null;
}
return (
<Row>
<Column align={align}>
<img
src={src}
alt={alt}
style={{
width: isDefined(width) ? width : 'auto',
height: 'auto',
maxWidth: '100%',
outline: 'none',
border: 'none',
textDecoration: 'none',
display: 'block',
}}
/>
</Column>
</Row>
);
};

View file

@ -0,0 +1,8 @@
import { Text } from '@react-email/components';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
export const paragraph = (node: JSONContent): ReactNode => {
return <Text>{mappedNodeContent(node)}</Text>;
};

View file

@ -0,0 +1,17 @@
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { renderMark } from 'src/utils/email-renderer/renderers/render-mark';
import { isDefined } from 'twenty-shared/utils';
export const text = (node: JSONContent): ReactNode => {
if (isDefined(node?.marks)) {
return renderMark(node);
}
const { text } = node;
if (!isDefined(text)) {
return <>&nbsp;</>;
}
return <>{text}</>;
};

View file

@ -0,0 +1,12 @@
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const variableTag = (node: JSONContent): ReactNode => {
const { variable } = node?.attrs || {};
if (!isDefined(variable)) {
return <>&nbsp;</>;
}
return <>{variable}</>;
};

View file

@ -0,0 +1,46 @@
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import {
type TipTapMark,
type TipTapMarkType,
TIPTAP_MARKS_RENDER_ORDER,
TIPTAP_MARK_TYPES,
} from 'twenty-shared/utils';
import { bold } from '../marks/bold';
import { italic } from '../marks/italic';
import { link } from '../marks/link';
import { strike } from '../marks/strike';
import { underline } from '../marks/underline';
const MARK_RENDERERS = {
[TIPTAP_MARK_TYPES.BOLD]: bold,
[TIPTAP_MARK_TYPES.ITALIC]: italic,
[TIPTAP_MARK_TYPES.UNDERLINE]: underline,
[TIPTAP_MARK_TYPES.STRIKE]: strike,
[TIPTAP_MARK_TYPES.LINK]: link,
} as const;
export const renderMark = (node: JSONContent): ReactNode => {
const text = node?.text || <>&nbsp;</>;
const marks = (node?.marks as TipTapMark[]) || [];
// Sort marks according to the defined render order
marks.sort((a, b) => {
return (
TIPTAP_MARKS_RENDER_ORDER.indexOf(a.type) -
TIPTAP_MARKS_RENDER_ORDER.indexOf(b.type)
);
});
// Apply marks from innermost to outermost
return marks.reduce((children: ReactNode, mark: TipTapMark) => {
const renderer = MARK_RENDERERS[mark.type as TipTapMarkType];
if (!renderer) {
// Fallback for unknown mark types - skip unknown marks
return children;
}
return renderer(mark, children);
}, text);
};

View file

@ -0,0 +1,40 @@
import { type JSONContent } from '@tiptap/core';
import { Fragment, type ReactNode } from 'react';
import { TIPTAP_NODE_TYPES, type TipTapNodeType } from 'twenty-shared/utils';
import { heading } from '../nodes/heading';
import { image } from '../nodes/image';
import { paragraph } from '../nodes/paragraph';
import { text } from '../nodes/text';
import { variableTag } from '../nodes/variable-tag';
const NODE_RENDERERS = {
[TIPTAP_NODE_TYPES.PARAGRAPH]: paragraph,
[TIPTAP_NODE_TYPES.TEXT]: text,
[TIPTAP_NODE_TYPES.HEADING]: heading,
[TIPTAP_NODE_TYPES.VARIABLE_TAG]: variableTag,
[TIPTAP_NODE_TYPES.IMAGE]: image,
};
const renderNode = (node: JSONContent): ReactNode => {
const renderer = NODE_RENDERERS[node.type as TipTapNodeType];
if (!renderer) {
return null;
}
return renderer(node);
};
export const mappedNodeContent = (node: JSONContent): JSX.Element[] => {
const allNodes = node.content || [];
return allNodes
.map((childNode, index) => {
const component = renderNode(childNode);
if (!component) {
return null;
}
return <Fragment key={index}>{component}</Fragment>;
})
.filter((n) => n !== null);
};

View file

@ -53,10 +53,17 @@
"@react-pdf/renderer": "^4.1.6",
"@scalar/api-reference-react": "^0.4.36",
"@tiptap/core": "^3.4.2",
"@tiptap/extension-bold": "^3.4.2",
"@tiptap/extension-document": "^3.4.2",
"@tiptap/extension-hard-break": "^3.4.2",
"@tiptap/extension-heading": "^3.4.2",
"@tiptap/extension-image": "^3.4.4",
"@tiptap/extension-italic": "^3.4.2",
"@tiptap/extension-link": "^3.4.2",
"@tiptap/extension-paragraph": "^3.4.2",
"@tiptap/extension-strike": "^3.4.2",
"@tiptap/extension-text": "^3.4.2",
"@tiptap/extension-underline": "^3.4.2",
"@tiptap/extensions": "^3.4.2",
"@tiptap/react": "^3.4.2",
"@xyflow/react": "^12.4.2",

View file

@ -0,0 +1,89 @@
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
import {
Breadcrumb,
type BreadcrumbProps,
} from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import styled from '@emotion/styled';
import { useRef } from 'react';
import { createPortal } from 'react-dom';
const StyledFullScreenOverlay = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${({ theme }) => theme.background.noisy};
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
z-index: ${RootStackingContextZIndices.RootModal};
`;
const StyledFullScreenHeader = styled(PageHeader)`
padding-left: ${({ theme }) => theme.spacing(3)};
`;
const StyledFullScreenContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
flex: 1;
min-height: 0;
padding: ${({ theme }) =>
`0 ${theme.spacing(3)} ${theme.spacing(3)} ${theme.spacing(3)}`};
// Make the immediate child a flex column that grows, so nested components
// with height="100%" (e.g., editors) can size correctly.
> * {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
row-gap: ${({ theme }) => theme.spacing(5)};
}
`;
type UseFullScreenModalProps = {
links: BreadcrumbProps['links'];
onClose: () => void;
hasClosePageButton?: boolean;
};
export const useFullScreenModal = ({
links,
onClose,
hasClosePageButton = true,
}: UseFullScreenModalProps) => {
const overlayRef = useRef<HTMLDivElement>(null);
const renderFullScreenModal = (
children: React.ReactNode,
isOpen: boolean,
) => {
if (!isOpen) return null;
return createPortal(
<StyledFullScreenOverlay
ref={overlayRef}
data-globally-prevent-click-outside="true"
tabIndex={-1}
>
<StyledFullScreenHeader
title={<Breadcrumb links={links} />}
hasClosePageButton={hasClosePageButton}
onClosePage={onClose}
/>
<StyledFullScreenContent>{children}</StyledFullScreenContent>
</StyledFullScreenOverlay>,
document.body,
);
};
return {
overlayRef,
renderFullScreenModal,
};
};

View file

@ -1,13 +1,8 @@
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
import { PAGE_BAR_MIN_HEIGHT } from '@/ui/layout/page/constants/PageBarMinHeight';
import {
Breadcrumb,
type BreadcrumbProps,
} from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useFullScreenModal } from '@/ui/layout/fullscreen/hooks/useFullScreenModal';
import { type BreadcrumbProps } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
@ -47,8 +42,7 @@ import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/Workf
import { type Monaco } from '@monaco-editor/react';
import { type editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
@ -68,35 +62,6 @@ const StyledCodeEditorContainer = styled.div`
overflow: hidden;
`;
const StyledFullScreenOverlay = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${({ theme }) => theme.background.noisy};
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
z-index: ${RootStackingContextZIndices.Dialog};
`;
const StyledFullScreenHeader = styled(PageHeader)`
padding-left: ${({ theme }) => theme.spacing(3)};
`;
const StyledFullScreenContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
height: calc(
100% - ${PAGE_BAR_MIN_HEIGHT}px - ${({ theme }) => theme.spacing(2 * 2 + 5)}
);
padding: ${({ theme }) =>
`0 ${theme.spacing(3)} ${theme.spacing(3)} ${theme.spacing(3)}`};
`;
const StyledTabList = styled(TabList)`
background-color: ${({ theme }) => theme.background.secondary};
padding-left: ${({ theme }) => theme.spacing(2)};
@ -131,7 +96,6 @@ export const WorkflowEditActionServerlessFunction = ({
const { t } = useLingui();
const [isFullScreen, setIsFullScreen] = useState(false);
const isMobile = useIsMobile();
const fullScreenOverlayRef = useRef<HTMLDivElement>(null);
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
const fullScreenFocusId = `code-editor-fullscreen-${serverlessFunctionId}`;
const activeTabId = useRecoilComponentValue(
@ -352,17 +316,6 @@ export const WorkflowEditActionServerlessFunction = ({
dependencies: [isFullScreen],
});
useListenClickOutside({
refs: [fullScreenOverlayRef],
callback: () => {
if (isFullScreen) {
handleExitFullScreen();
}
},
listenerId: `full-screen-overlay-${serverlessFunctionId}`,
enabled: isFullScreen,
});
const headerTitle = isDefined(action.name)
? action.name
: 'Code - Serverless Function';
@ -372,19 +325,6 @@ export const WorkflowEditActionServerlessFunction = ({
const testLogsTextAreaId = `${serverlessFunctionId}-test-logs`;
const handleEnterFullScreen = () => {
setIsFullScreen(true);
setTimeout(() => {
if (isDefined(fullScreenOverlayRef.current)) {
fullScreenOverlayRef.current.focus();
}
}, 0);
};
const handleExitFullScreen = () => {
setIsFullScreen(false);
};
const breadcrumbLinks: BreadcrumbProps['links'] = [
{
children: workflow?.name?.trim() || t`Untitled Workflow`,
@ -399,46 +339,64 @@ export const WorkflowEditActionServerlessFunction = ({
},
];
const fullScreenOverlay = isFullScreen
? createPortal(
<StyledFullScreenOverlay
ref={fullScreenOverlayRef}
data-globally-prevent-click-outside="true"
tabIndex={-1}
>
<StyledFullScreenHeader
title={<Breadcrumb links={breadcrumbLinks} />}
hasClosePageButton={!isMobile}
onClosePage={handleExitFullScreen}
/>
<StyledFullScreenContent data-globally-prevent-click-outside="true">
<WorkflowEditActionServerlessFunctionFields
functionInput={functionInput}
VariablePicker={WorkflowVariablePicker}
onInputChange={handleInputChange}
readonly={actionOptions.readonly}
/>
<StyledFullScreenCodeEditorContainer>
<CodeEditor
height="100%"
value={formValues.code?.[INDEX_FILE_PATH]}
language="typescript"
onChange={handleCodeChange}
onMount={handleEditorDidMount}
setMarkers={getWrongExportedFunctionMarkers}
options={{
readOnly: actionOptions.readonly,
domReadOnly: actionOptions.readonly,
scrollBeyondLastLine: false,
padding: { top: 4, bottom: 4 },
}}
/>
</StyledFullScreenCodeEditorContainer>
</StyledFullScreenContent>
</StyledFullScreenOverlay>,
document.body,
)
: null;
const { overlayRef: fullScreenOverlayRef, renderFullScreenModal } =
useFullScreenModal({
links: breadcrumbLinks,
onClose: () => setIsFullScreen(false),
hasClosePageButton: !isMobile,
});
useListenClickOutside({
refs: [fullScreenOverlayRef],
callback: () => {
if (isFullScreen) {
handleExitFullScreen();
}
},
listenerId: `full-screen-overlay-${serverlessFunctionId}`,
enabled: isFullScreen,
});
const handleEnterFullScreen = () => {
setIsFullScreen(true);
setTimeout(() => {
if (isDefined(fullScreenOverlayRef.current)) {
fullScreenOverlayRef.current.focus();
}
}, 0);
};
const handleExitFullScreen = () => {
setIsFullScreen(false);
};
const fullScreenOverlay = renderFullScreenModal(
<div data-globally-prevent-click-outside="true">
<WorkflowEditActionServerlessFunctionFields
functionInput={functionInput}
VariablePicker={WorkflowVariablePicker}
onInputChange={handleInputChange}
readonly={actionOptions.readonly}
/>
<StyledFullScreenCodeEditorContainer>
<CodeEditor
height="100%"
value={formValues.code?.[INDEX_FILE_PATH]}
language="typescript"
onChange={handleCodeChange}
onMount={handleEditorDidMount}
setMarkers={getWrongExportedFunctionMarkers}
options={{
readOnly: actionOptions.readonly,
domReadOnly: actionOptions.readonly,
scrollBeyondLastLine: false,
padding: { top: 4, bottom: 4 },
}}
/>
</StyledFullScreenCodeEditorContainer>
</div>,
isFullScreen,
);
return (
!loading && (

View file

@ -0,0 +1,222 @@
import { type WorkflowCodeAction } from '@/workflow/types/Workflow';
import { WorkflowEditActionServerlessFunction } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction';
import { type Meta, type StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { graphql, HttpResponse } from 'msw';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
const DEFAULT_ACTION: WorkflowCodeAction = {
id: getWorkflowNodeIdMock(),
name: 'Code',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: 'draft',
serverlessFunctionInput: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
};
const CONFIGURED_ACTION: WorkflowCodeAction = {
id: getWorkflowNodeIdMock(),
name: 'Process Data',
type: 'CODE',
valid: true,
settings: {
input: {
serverlessFunctionId: 'test-function-id',
serverlessFunctionVersion: 'draft',
serverlessFunctionInput: {
name: 'John Doe',
email: 'john@example.com',
score: 95,
},
},
outputSchema: {
result: {
type: 'TEXT',
label: 'Result',
value: 'Processing completed',
isLeaf: true,
},
status: {
type: 'TEXT',
label: 'Status',
value: 'success',
isLeaf: true,
},
},
errorHandlingOptions: {
retryOnFailure: {
value: true,
},
continueOnFailure: {
value: false,
},
},
},
};
const meta: Meta<typeof WorkflowEditActionServerlessFunction> = {
title: 'Modules/Workflow/Actions/Code/EditAction',
component: WorkflowEditActionServerlessFunction,
parameters: {
msw: {
handlers: [
...graphqlMocks.handlers,
graphql.query('FindManyServerlessFunctions', () => {
return HttpResponse.json({
data: {
findManyServerlessFunctions: [
{
id: 'test-function-id',
name: 'Test Function',
description: 'A test serverless function',
runtime: 'nodejs22.x',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
},
});
}),
graphql.query('FindOneServerlessFunction', () => {
return HttpResponse.json({
data: {
findOneServerlessFunction: {
id: 'test-function-id',
name: 'Test Function',
description: 'A test serverless function',
runtime: 'nodejs22.x',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
},
});
}),
graphql.query('FindManyAvailablePackages', () => {
return HttpResponse.json({
data: {
getAvailablePackages: ['axios', 'lodash', 'moment'],
},
});
}),
],
},
},
args: {
action: DEFAULT_ACTION,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
RouterDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowEditActionServerlessFunction>;
export const Default: Story = {
args: {
actionOptions: {
onActionUpdate: fn(),
},
},
};
export const Configured: Story = {
args: {
action: CONFIGURED_ACTION,
actionOptions: {
onActionUpdate: fn(),
},
},
};
export const ReadOnly: Story = {
args: {
action: CONFIGURED_ACTION,
actionOptions: {
readonly: true,
},
},
};
export const WithTestResults: Story = {
args: {
action: {
...CONFIGURED_ACTION,
settings: {
...CONFIGURED_ACTION.settings,
outputSchema: {
result: {
type: 'TEXT',
label: 'Processing Result',
value: 'Successfully processed 150 records',
isLeaf: true,
},
executionTime: {
type: 'NUMBER',
label: 'Execution Time (ms)',
value: 245,
isLeaf: true,
},
errors: {
type: 'ARRAY',
label: 'Errors',
value: [],
isLeaf: true,
},
},
},
},
actionOptions: {
onActionUpdate: fn(),
},
},
};
export const EmptyFunction: Story = {
args: {
action: {
...DEFAULT_ACTION,
settings: {
...DEFAULT_ACTION.settings,
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: 'draft',
serverlessFunctionInput: {},
},
},
},
actionOptions: {
onActionUpdate: fn(),
},
},
};

View file

@ -14,6 +14,7 @@ import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { WorkflowSendEmailBody } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
@ -220,6 +221,7 @@ export const WorkflowEditActionSendEmail = ({
const navigate = useNavigateSettings();
const { closeCommandMenu } = useCommandMenu();
return (
!loading && (
<>
@ -283,7 +285,8 @@ export const WorkflowEditActionSendEmail = ({
}}
VariablePicker={WorkflowVariablePicker}
/>
<FormTextFieldInput
<WorkflowSendEmailBody
action={action}
label="Body"
placeholder="Enter email body"
readonly={actionOptions.readonly}
@ -292,7 +295,6 @@ export const WorkflowEditActionSendEmail = ({
handleFieldChange('body', body);
}}
VariablePicker={WorkflowVariablePicker}
multiline
/>
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}

View file

@ -0,0 +1,98 @@
import { FORM_FIELD_PLACEHOLDER_STYLES } from '@/object-record/record-field/ui/form-types/constants/FormFieldPlaceholderStyles';
import { ImageBubbleMenu } from '@/workflow/workflow-steps/workflow-actions/email-action/components/image-bubble-menu/ImageBubbleMenu';
import { LinkBubbleMenu } from '@/workflow/workflow-steps/workflow-actions/email-action/components/link-bubble-menu/LinkBubbleMenu';
import { TextBubbleMenu } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu';
import styled from '@emotion/styled';
import { EditorContent, type Editor } from '@tiptap/react';
const EMAIL_EDITOR_MIN_HEIGHT = 340;
const EMAIL_EDITOR_MAX_WIDTH = 600;
const StyledEditorContainer = styled.div<{
readonly?: boolean;
}>`
height: 100%;
display: flex;
flex-direction: column;
width: 100%;
box-sizing: border-box;
.editor-content {
flex-grow: 1;
width: 100%;
height: 100%;
min-height: ${EMAIL_EDITOR_MIN_HEIGHT}px;
max-width: ${EMAIL_EDITOR_MAX_WIDTH}px;
margin: 0 auto;
}
.tiptap {
padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
box-sizing: border-box;
height: 100%;
color: ${({ theme, readonly }) =>
readonly ? theme.font.color.light : theme.font.color.primary};
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
border: none !important;
p.is-editor-empty:first-of-type::before {
${FORM_FIELD_PLACEHOLDER_STYLES}
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
p {
line-height: 1.5;
margin: 0;
}
.variable-tag {
background-color: ${({ theme }) => theme.color.blue10};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.color.blue};
padding: ${({ theme }) => theme.spacing(1)};
}
h1 {
font-size: 36px;
}
h2 {
font-size: 30px;
}
h3 {
font-size: 24px;
}
}
.ProseMirror-focused {
outline: none;
}
.ProseMirror-hideselection * {
caret-color: transparent;
}
`;
type WorkflowEmailEditorProps = {
readonly: boolean | undefined;
editor: Editor;
};
export const WorkflowEmailEditor = ({
readonly,
editor,
}: WorkflowEmailEditorProps) => {
return (
<StyledEditorContainer readonly={readonly}>
<EditorContent className="editor-content" editor={editor} />
<ImageBubbleMenu editor={editor} />
<TextBubbleMenu editor={editor} />
<LinkBubbleMenu editor={editor} />
</StyledEditorContainer>
);
};

View file

@ -0,0 +1,261 @@
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
import { InputHint } from '@/ui/input/components/InputHint';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { StyledDropdownButtonContainer } from '@/ui/layout/dropdown/components/StyledDropdownButtonContainer';
import { useFullScreenModal } from '@/ui/layout/fullscreen/hooks/useFullScreenModal';
import { type BreadcrumbProps } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { WorkflowEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor';
import { useEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useId, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconMaximize } from 'twenty-ui/display';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledWorkflowSendEmailBodyContainer = styled(FormFieldInputContainer)`
flex-grow: 1;
`;
const StyledWorkflowSendEmailFieldContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
flex-grow: 1;
`;
const StyledWorkflowSendEmailBodyInnerContainer = styled.div`
flex-grow: 1;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
display: flex;
overflow: auto;
width: 100%;
`;
const StyledEmailEditorActionButtonContainer = styled.div`
position: absolute;
top: ${({ theme }) => theme.spacing(0)};
right: ${({ theme }) => theme.spacing(7.5)};
z-index: 1;
`;
const StyledFullScreenEmailEditorContainer = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
flex: 1;
min-height: 0;
padding: ${({ theme }) => theme.spacing(2)};
overflow-y: auto;
`;
const StyledFullScreenButtonContainer = styled(StyledDropdownButtonContainer)`
background-color: 'transparent';
color: ${({ theme }) => theme.font.color.tertiary};
padding: ${({ theme }) => theme.spacing(2)};
:hover {
cursor: pointer;
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
type WorkflowSendEmailBodyProps = {
action: WorkflowSendEmailAction;
label?: string;
error?: string;
hint?: string;
defaultValue: string | undefined | null;
onChange: (value: string) => void;
readonly?: boolean;
placeholder?: string;
VariablePicker?: VariablePickerComponent;
};
export const WorkflowSendEmailBody = ({
action,
label,
error,
hint,
defaultValue,
placeholder,
onChange,
readonly,
VariablePicker,
}: WorkflowSendEmailBodyProps) => {
const instanceId = useId();
const isMobile = useIsMobile();
const [isFullScreen, setIsFullScreen] = useState(false);
const theme = useTheme();
const { uploadAttachmentFile } = useUploadAttachmentFile();
const { enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId);
const headerTitle = isDefined(action.name) ? action.name : 'Send Email';
const handleUploadAttachment = async (file: File) => {
if (!isDefined(workflowVisualizerWorkflowId)) {
return undefined;
}
const { attachmentAbsoluteURL } = await uploadAttachmentFile(file, {
id: workflowVisualizerWorkflowId,
targetObjectNameSingular: CoreObjectNameSingular.Workflow,
});
return attachmentAbsoluteURL;
};
const handleImageUploadError = (error: Error, file: File) => {
enqueueErrorSnackBar({
message: t`Failed to upload image: `.concat(file.name),
});
};
const editor = useEmailEditor(
{
placeholder: placeholder ?? 'Enter email',
readonly,
defaultValue,
onUpdate: (editor) => {
const jsonContent = editor.getJSON();
onChange(JSON.stringify(jsonContent));
},
onFocus: () => {
pushFocusItemToFocusStack({
focusId: instanceId,
component: {
type: FocusComponentType.FORM_FIELD_INPUT,
instanceId: instanceId,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
},
onBlur: () => {
removeFocusItemFromFocusStackById({ focusId: instanceId });
},
onImageUpload: handleUploadAttachment,
onImageUploadError: handleImageUploadError,
},
[isFullScreen],
);
const handleEnterFullScreen = () => {
setIsFullScreen(true);
};
const handleExitFullScreen = () => {
setIsFullScreen(false);
};
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);
};
const breadcrumbLinks: BreadcrumbProps['links'] = [
{
children: workflow?.name?.trim() || t`Untitled Workflow`,
href: '#',
},
{
children: headerTitle,
href: '#',
},
{
children: t`Email Editor`,
},
];
const { renderFullScreenModal } = useFullScreenModal({
links: breadcrumbLinks,
onClose: handleExitFullScreen,
hasClosePageButton: !isMobile,
});
const fullScreenOverlay = renderFullScreenModal(
<div data-globally-prevent-click-outside="true">
<StyledFullScreenEmailEditorContainer>
<WorkflowEmailEditor editor={editor} readonly={readonly} />
</StyledFullScreenEmailEditorContainer>
</div>,
isFullScreen,
);
if (!isDefined(editor)) {
return null;
}
return (
<>
<StyledWorkflowSendEmailBodyContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledWorkflowSendEmailFieldContainer>
<StyledWorkflowSendEmailBodyInnerContainer>
{!isFullScreen && (
<WorkflowEmailEditor editor={editor} readonly={readonly} />
)}
<StyledEmailEditorActionButtonContainer>
{!readonly && !isFullScreen && (
<StyledFullScreenButtonContainer
isUnfolded={false}
transparentBackground
onClick={handleEnterFullScreen}
>
<IconMaximize size={theme.icon.size.md} />
</StyledFullScreenButtonContainer>
)}
</StyledEmailEditorActionButtonContainer>
{VariablePicker && !readonly ? (
<VariablePicker
instanceId={instanceId}
multiline={true}
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</StyledWorkflowSendEmailBodyInnerContainer>
</StyledWorkflowSendEmailFieldContainer>
{hint && <InputHint>{hint}</InputHint>}
{error && <InputErrorHelper>{error}</InputErrorHelper>}
</StyledWorkflowSendEmailBodyContainer>
{fullScreenOverlay}
</>
);
};

View file

@ -0,0 +1,276 @@
import { WorkflowEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor';
import { useEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor';
import { type Meta, type StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
const EditorWrapper = ({
readonly = false,
placeholder = 'Enter email content...',
defaultValue = null,
onUpdate = fn(),
}: {
readonly?: boolean;
placeholder?: string;
defaultValue?: string | null;
onUpdate?: (content: string) => void;
}) => {
const editor = useEmailEditor({
placeholder,
readonly,
defaultValue,
onUpdate: (editor) => {
const jsonContent = editor.getJSON();
onUpdate(JSON.stringify(jsonContent));
},
onImageUpload: async (file: File) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return `https://via.placeholder.com/400x200?text=${encodeURIComponent(file.name)}`;
},
onImageUploadError: (_error: Error, _file: File) => {
// Handle image upload error
},
});
if (!editor) {
return <div>Loading editor...</div>;
}
return <WorkflowEmailEditor editor={editor} readonly={readonly} />;
};
const meta: Meta<typeof EditorWrapper> = {
title: 'Modules/Workflow/Actions/SendEmail/EmailEditor',
component: EditorWrapper,
parameters: {
msw: graphqlMocks,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
RouterDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
],
};
export default meta;
type Story = StoryObj<typeof EditorWrapper>;
export const Default: Story = {
args: {},
};
export const WithContent: Story = {
args: {
defaultValue: JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hello ',
},
{
type: 'text',
marks: [{ type: 'bold' }],
text: 'John',
},
{
type: 'text',
text: ',',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This is a sample email with ',
},
{
type: 'text',
marks: [{ type: 'italic' }],
text: 'italic',
},
{
type: 'text',
text: ' and ',
},
{
type: 'text',
marks: [{ type: 'underline' }],
text: 'underlined',
},
{
type: 'text',
text: ' text.',
},
],
},
],
}),
},
};
export const WithHeadings: Story = {
args: {
defaultValue: JSON.stringify({
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Welcome Email' }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Getting Started' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Thank you for joining us! Here are some next steps:',
},
],
},
{
type: 'heading',
attrs: { level: 3 },
content: [{ type: 'text', text: 'Next Steps' }],
},
{
type: 'paragraph',
content: [{ type: 'text', text: '1. Complete your profile' }],
},
],
}),
},
};
export const WithLinks: Story = {
args: {
defaultValue: JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Visit our ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://twenty.com' } }],
text: 'website',
},
{ type: 'text', text: ' for more information.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Contact us at ' },
{
type: 'text',
marks: [
{ type: 'link', attrs: { href: 'mailto:support@twenty.com' } },
],
text: 'support@twenty.com',
},
],
},
],
}),
},
};
export const WithVariableTags: Story = {
args: {
defaultValue: JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Dear ' },
{
type: 'variableTag',
attrs: { variable: '{{firstName}}' },
},
{ type: 'text', text: ',' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Your order ' },
{
type: 'variableTag',
attrs: { variable: '{{orderNumber}}' },
},
{ type: 'text', text: ' has been shipped!' },
],
},
],
}),
},
};
export const ReadOnly: Story = {
args: {
readonly: true,
defaultValue: JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [{ type: 'bold' }],
text: 'Read-only mode',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This editor is in read-only mode and cannot be edited.',
},
],
},
],
}),
},
};
export const Empty: Story = {
args: {
placeholder: 'Start typing your email content...',
},
};
export const Interactive: Story = {
args: {
onUpdate: fn(),
placeholder: 'Try typing, formatting text, or uploading images...',
},
};

View file

@ -0,0 +1,320 @@
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { WorkflowSendEmailBody } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { graphql, HttpResponse } from 'msw';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
const DEFAULT_ACTION: WorkflowSendEmailAction = {
id: getWorkflowNodeIdMock(),
name: 'Send Email',
type: 'SEND_EMAIL',
valid: false,
settings: {
input: {
connectedAccountId: '',
email: '',
subject: '',
body: '',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
const RICH_CONTENT_ACTION: WorkflowSendEmailAction = {
id: getWorkflowNodeIdMock(),
name: 'Welcome Email',
type: 'SEND_EMAIL',
valid: true,
settings: {
input: {
connectedAccountId: 'test-account-id',
email: 'user@example.com',
subject: 'Welcome to Twenty!',
body: JSON.stringify({
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Welcome to Twenty!' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Dear ' },
{
type: 'variableTag',
attrs: { variable: '{{firstName}}' },
},
{ type: 'text', text: ',' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Thank you for joining us! We are ' },
{
type: 'text',
marks: [{ type: 'bold' }],
text: 'excited',
},
{ type: 'text', text: ' to have you on board.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Visit our ' },
{
type: 'text',
marks: [
{ type: 'link', attrs: { href: 'https://twenty.com' } },
],
text: 'website',
},
{ type: 'text', text: ' to get started.' },
],
},
],
}),
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
},
};
const meta: Meta<typeof WorkflowSendEmailBody> = {
title: 'Modules/Workflow/Actions/SendEmail/EmailBody',
component: WorkflowSendEmailBody,
parameters: {
msw: {
handlers: [
...graphqlMocks.handlers,
graphql.query('FindManyWorkflows', () => {
return HttpResponse.json({
data: {
workflows: [
{
id: getWorkflowNodeIdMock(),
name: 'Test Workflow',
__typename: 'Workflow',
},
],
},
});
}),
],
},
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
RouterDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
],
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
action: DEFAULT_ACTION,
label: 'Email Body',
placeholder: 'Enter your email content...',
defaultValue: '',
onChange: fn(),
readonly: false,
VariablePicker: WorkflowVariablePicker,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Email Body')).toBeVisible();
expect(await canvas.findByRole('textbox')).toBeVisible();
},
};
export const WithRichContent: Story = {
args: {
action: RICH_CONTENT_ACTION,
label: 'Email Body',
placeholder: 'Enter your email content...',
defaultValue: RICH_CONTENT_ACTION.settings.input.body,
onChange: fn(),
readonly: false,
VariablePicker: WorkflowVariablePicker,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Email Body')).toBeVisible();
expect(await canvas.findByText('Welcome to Twenty!')).toBeVisible();
expect(await canvas.findByText('excited')).toBeVisible();
expect(await canvas.findByText('website')).toBeVisible();
},
};
export const ReadOnly: Story = {
args: {
action: RICH_CONTENT_ACTION,
label: 'Email Body (Read Only)',
defaultValue: RICH_CONTENT_ACTION.settings.input.body,
onChange: fn(),
readonly: true,
VariablePicker: WorkflowVariablePicker,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Email Body (Read Only)')).toBeVisible();
expect(await canvas.findByText('Welcome to Twenty!')).toBeVisible();
},
};
export const WithError: Story = {
args: {
action: DEFAULT_ACTION,
label: 'Email Body',
error: 'Email body is required',
placeholder: 'Enter your email content...',
defaultValue: '',
onChange: fn(),
readonly: false,
VariablePicker: WorkflowVariablePicker,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Email Body')).toBeVisible();
expect(await canvas.findByText('Email body is required')).toBeVisible();
},
};
export const WithHint: Story = {
args: {
action: DEFAULT_ACTION,
label: 'Email Body',
hint: 'You can use variables, format text, and add images to your email content.',
placeholder: 'Enter your email content...',
defaultValue: '',
onChange: fn(),
readonly: false,
VariablePicker: WorkflowVariablePicker,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Email Body')).toBeVisible();
expect(
await canvas.findByText(
'You can use variables, format text, and add images to your email content.',
),
).toBeVisible();
},
};
export const Interactive: Story = {
args: {
action: DEFAULT_ACTION,
label: 'Email Body',
placeholder: 'Start typing to see the editor in action...',
defaultValue: '',
onChange: fn(),
readonly: false,
VariablePicker: WorkflowVariablePicker,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Email Body')).toBeVisible();
const editor = await canvas.findByRole('textbox');
expect(editor).toBeVisible();
await userEvent.click(editor);
await userEvent.type(editor, 'Hello World!');
},
};
export const WithoutVariablePicker: Story = {
args: {
action: DEFAULT_ACTION,
label: 'Email Body',
placeholder: 'Enter your email content...',
defaultValue: '',
onChange: fn(),
readonly: false,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Email Body')).toBeVisible();
expect(await canvas.findByRole('textbox')).toBeVisible();
},
};
export const WithLongContent: Story = {
args: {
action: {
...DEFAULT_ACTION,
settings: {
...DEFAULT_ACTION.settings,
input: {
...DEFAULT_ACTION.settings.input,
body: JSON.stringify({
type: 'doc',
content: Array.from({ length: 20 }, (_, i) => ({
type: 'paragraph',
content: [
{
type: 'text',
text: `This is paragraph ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.`,
},
],
})),
}),
},
},
},
label: 'Email Body',
placeholder: 'Enter your email content...',
defaultValue: JSON.stringify({
type: 'doc',
content: Array.from({ length: 20 }, (_, i) => ({
type: 'paragraph',
content: [
{
type: 'text',
text: `This is paragraph ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.`,
},
],
})),
}),
onChange: fn(),
readonly: false,
VariablePicker: WorkflowVariablePicker,
},
};

View file

@ -0,0 +1,96 @@
import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton';
import { StyledBubbleMenuContainer } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu';
import { type Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/react/menus';
import {
IconAlignCenter,
IconAlignLeft,
IconAlignRight,
IconTrash,
} from 'twenty-ui/display';
type ImageBubbleMenuProps = {
editor: Editor;
};
export const ImageBubbleMenu = ({ editor }: ImageBubbleMenuProps) => {
const state = useEditorState({
editor,
selector: (ctx) => {
return {
align: ctx.editor.getAttributes('image').align || 'left',
};
},
});
const handleDelete = () => {
editor.chain().focus().deleteSelection().run();
};
const alignmentActions = [
{
align: 'left',
Icon: IconAlignLeft,
onClick: () =>
editor
.chain()
.focus()
.updateAttributes('image', { align: 'left' })
.run(),
isActive: state.align === 'left',
},
{
align: 'center',
Icon: IconAlignCenter,
onClick: () =>
editor
.chain()
.focus()
.updateAttributes('image', { align: 'center' })
.run(),
isActive: state.align === 'center',
},
{
align: 'right',
Icon: IconAlignRight,
onClick: () =>
editor
.chain()
.focus()
.updateAttributes('image', { align: 'right' })
.run(),
isActive: state.align === 'right',
},
];
const handleShouldShow = () => {
return editor.isActive('image');
};
return (
<BubbleMenu
pluginKey="image-bubble-menu"
editor={editor}
shouldShow={handleShouldShow}
updateDelay={0}
>
<StyledBubbleMenuContainer>
{alignmentActions.map(({ align, Icon, onClick, isActive }) => (
<BubbleMenuIconButton
key={`image-align-${align}`}
Icon={Icon}
onClick={onClick}
isActive={isActive}
/>
))}
<BubbleMenuIconButton
key="image-delete"
Icon={IconTrash}
onClick={handleDelete}
isActive={false}
/>
</StyledBubbleMenuContainer>
</BubbleMenu>
);
};

View file

@ -0,0 +1,62 @@
import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton';
import { EditLinkPopover } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover';
import { StyledBubbleMenuContainer } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu';
import { type Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/react/menus';
import { IconExternalLink, IconLinkOff } from 'twenty-ui/display';
type LinkBubbleMenuProps = {
editor: Editor;
};
export const LinkBubbleMenu = ({ editor }: LinkBubbleMenuProps) => {
const state = useEditorState({
editor,
selector: (ctx) => {
return {
linkHref: ctx.editor.getAttributes('link').href || '',
};
},
});
const handleShouldShow = () => {
return editor.isActive('link');
};
const menuActions = [
{
Icon: IconExternalLink,
onClick: () => {
window.open(state.linkHref, '_blank');
},
},
{
Icon: IconLinkOff,
onClick: () =>
editor.chain().focus().extendMarkRange('link').unsetLink().run(),
},
];
return (
<BubbleMenu
pluginKey="link-bubble-menu"
editor={editor}
shouldShow={handleShouldShow}
updateDelay={0}
>
<StyledBubbleMenuContainer>
<EditLinkPopover defaultValue={state.linkHref} editor={editor} />
{menuActions.map(({ Icon, onClick }) => {
return (
<BubbleMenuIconButton
key={Icon.name || Icon.displayName || 'unknown'}
Icon={Icon}
onClick={onClick}
/>
);
})}
</StyledBubbleMenuContainer>
</BubbleMenu>
);
};

View file

@ -0,0 +1,44 @@
import styled from '@emotion/styled';
import React from 'react';
import type { IconComponent } from 'twenty-ui/display';
import { FloatingIconButton } from 'twenty-ui/input';
type BubbleMenuIconButtonProps = {
className?: string;
Icon?: IconComponent;
disabled?: boolean;
focus?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
isActive?: boolean;
};
const StyledBubbleMenuIconButton = styled(FloatingIconButton)`
border: none;
border-radius: ${({ theme }) => theme.spacing(1.5)};
width: ${({ theme }) => theme.spacing(6)};
height: ${({ theme }) => theme.spacing(6)};
`;
export const BubbleMenuIconButton = ({
className,
Icon,
disabled = false,
focus = false,
onClick,
isActive,
}: BubbleMenuIconButtonProps) => {
return (
<StyledBubbleMenuIconButton
className={className}
Icon={Icon}
disabled={disabled}
focus={focus}
onClick={onClick}
isActive={isActive}
applyShadow={false}
applyBlur={false}
size="medium"
position="standalone"
/>
);
};

View file

@ -0,0 +1,79 @@
import { TextInput } from '@/ui/input/components/TextInput';
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 { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { type Editor } from '@tiptap/core';
import { useId, useState, type FocusEvent, type FormEvent } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconLink, IconPencil } from 'twenty-ui/display';
type EditLinkPopoverProps = {
defaultValue: string | undefined;
editor: Editor;
};
export const EditLinkPopover = ({
defaultValue = '',
editor,
}: EditLinkPopoverProps) => {
const instanceId = useId();
const dropdownId = `edit-link-popover-${instanceId}`;
const isActive = isNonEmptyString(defaultValue);
const { t } = useLingui();
const [value, setValue] = useState(defaultValue);
const { toggleDropdown } = useToggleDropdown();
const handleSubmit = (
event: FormEvent<HTMLFormElement> | FocusEvent<HTMLInputElement>,
) => {
event.preventDefault();
if (!isDefined(value)) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
} else {
editor
.chain()
.focus()
.extendMarkRange('link')
.setLink({ href: value })
.run();
}
toggleDropdown({ dropdownComponentInstanceIdFromProps: dropdownId });
};
return (
<Dropdown
onOpen={() => {
setValue(defaultValue);
}}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<form onSubmit={handleSubmit}>
<TextInput
placeholder={t`Enter link`}
value={value}
onChange={setValue}
onBlur={handleSubmit}
/>
</form>
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownId={dropdownId}
isDropdownInModal={true}
clickableComponent={
<BubbleMenuIconButton
isActive={isActive}
Icon={isActive ? IconPencil : IconLink}
/>
}
/>
);
};

View file

@ -0,0 +1,87 @@
import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton';
import { EditLinkPopover } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover';
import { TurnIntoBlockDropdown } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TurnIntoBlockDropdown';
import { useTextBubbleState } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useTextBubbleState';
import { isTextSelected } from '@/workflow/workflow-steps/workflow-actions/email-action/utils/isTextSelected';
import styled from '@emotion/styled';
import { type Editor } from '@tiptap/core';
import { BubbleMenu } from '@tiptap/react/menus';
import {
IconBold,
IconItalic,
IconStrikethrough,
IconUnderline,
} from 'twenty-ui/display';
export const StyledBubbleMenuContainer = styled.div`
backdrop-filter: blur(20px);
background-color: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) =>
`0px 2px 4px 0px ${theme.background.transparent.light}, 0px 0px 4px 0px ${theme.background.transparent.medium}`};
display: inline-flex;
gap: 2px;
padding: 2px;
`;
type TextBubbleMenuProps = {
editor: Editor;
};
export const TextBubbleMenu = ({ editor }: TextBubbleMenuProps) => {
const state = useTextBubbleState(editor);
const menuActions = [
{
Icon: IconBold,
onClick: () => editor.chain().focus().toggleBold().run(),
isActive: state.isBold,
},
{
Icon: IconItalic,
onClick: () => editor.chain().focus().toggleItalic().run(),
isActive: state.isItalic,
},
{
Icon: IconUnderline,
onClick: () => editor.chain().focus().toggleUnderline().run(),
isActive: state.isUnderline,
},
{
Icon: IconStrikethrough,
onClick: () => editor.chain().focus().toggleStrike().run(),
isActive: state.isStrike,
},
];
const handleShouldShow = () => {
if (editor.isActive('image')) {
return false;
}
return isTextSelected({ editor });
};
return (
<BubbleMenu
pluginKey="text-bubble-menu"
editor={editor}
shouldShow={handleShouldShow}
updateDelay={0}
>
<StyledBubbleMenuContainer>
<TurnIntoBlockDropdown editor={editor} />
{menuActions.map(({ Icon, onClick, isActive }) => {
return (
<BubbleMenuIconButton
key={Icon.name || Icon.displayName || 'unknown'}
Icon={Icon}
onClick={onClick}
isActive={isActive}
/>
);
})}
<EditLinkPopover defaultValue={state.linkHref} editor={editor} />
</StyledBubbleMenuContainer>
</BubbleMenu>
);
};

View file

@ -0,0 +1,89 @@
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 { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
import { useTurnIntoBlockOptions } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useTurnIntoBlockOptions';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { type Editor } from '@tiptap/react';
import { useId } from 'react';
import { IconPilcrow } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
const StyledMenuItem = styled.button`
align-items: center;
background: none;
border: none;
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: 4px;
height: ${({ theme }) => theme.spacing(6)};
padding: 0;
width: 100%;
padding: 0 ${({ theme }) => theme.spacing(1.5)};
border-radius: ${({ theme }) => theme.spacing(1.5)};
:hover {
background: ${({ theme }) => theme.background.transparent.medium};
}
:focus {
outline: none;
}
`;
type TurnIntoBlockDropdownProps = {
editor: Editor;
};
export const TurnIntoBlockDropdown = ({
editor,
}: TurnIntoBlockDropdownProps) => {
const theme = useTheme();
const instanceId = useId();
const dropdownId = `turn-into-block-dropdown-${instanceId}`;
const { toggleDropdown } = useToggleDropdown();
const options = useTurnIntoBlockOptions(editor);
const activeItem = options.find((option) => option.isActive());
const { icon: ActiveIcon = IconPilcrow, title: activeTitle = 'Paragraph' } =
activeItem ?? {};
return (
<Dropdown
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
{options.map(({ id, title, icon, onClick }) => (
<MenuItem
key={id}
text={title}
LeftIcon={icon}
onClick={() => {
onClick();
toggleDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
}}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownId={dropdownId}
clickableComponent={
<StyledMenuItem>
<ActiveIcon size={theme.icon.size.md} />
{activeTitle}
</StyledMenuItem>
}
dropdownOffset={{
y: parseInt(theme.spacing(1), 10),
}}
/>
);
};

View file

@ -0,0 +1,21 @@
import { ResizableImageView } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImageView';
import {
type ImageOptions,
Image as TiptapImage,
} from '@tiptap/extension-image';
import { ReactNodeViewRenderer } from '@tiptap/react';
export const ResizableImage = TiptapImage.extend<ImageOptions>({
addAttributes() {
return {
...this.parent?.(),
align: {
default: 'left',
},
};
},
addNodeView: () => {
return ReactNodeViewRenderer(ResizableImageView);
},
});

View file

@ -0,0 +1,215 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { type NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import React, { useCallback, useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
const IMAGE_MIN_WIDTH = 32;
const IMAGE_MAX_WIDTH = 600;
const StyledNodeViewWrapper = styled(NodeViewWrapper)`
height: 100%;
${({ align }) => {
switch (align) {
case 'left':
return css`
margin-left: 0;
`;
case 'right':
return css`
margin-right: 0;
`;
case 'center':
return css`
margin-left: auto;
margin-right: auto;
`;
}
}}
`;
const StyledImageWrapper = styled.div<{ width?: number }>`
height: 100%;
`;
const StyledImageContainer = styled.div`
max-width: 100%;
position: relative;
`;
const StyledImage = styled.img`
height: auto;
max-width: 100%;
`;
const StyledImageHandle = styled.div<{ handle: 'left' | 'right' }>`
border-radius: ${({ theme }) => theme.border.radius.md};
background-color: ${({ theme }) => theme.background.primaryInverted};
border: 1px solid ${({ theme }) => theme.background.primary};
cursor: col-resize;
height: ${({ theme }) => theme.spacing(8)};
position: absolute;
top: 50%;
transform: translateY(-50%);
width: ${({ theme }) => theme.spacing(2)};
z-index: 1;
${({ handle, theme }) => {
if (handle === 'left') {
return css`
left: ${theme.spacing(1)};
`;
}
return css`
right: ${theme.spacing(1)};
`;
}}
`;
type ResizeParams = {
initialWidth: number;
initialClientX: number;
handleUsed: 'left' | 'right';
};
type ResizableImageViewProps = NodeViewProps;
export const ResizableImageView = (props: ResizableImageViewProps) => {
const { editor, node, updateAttributes } = props;
const { width: initialWidth, align = 'left', src, alt } = node.attrs;
const imageWrapperRef = useRef<HTMLDivElement>(null);
// Controls visibility of resize handles when hovering over the image
const [isHovering, setIsHovering] = useState(false);
const [width, setWidth] = useState(initialWidth || 0);
// Controls actual resize operation state (null = not resizing, object = actively resizing)
const [resizeParams, setResizeParams] = useState<ResizeParams | null>(null);
// Create stable event handlers using a closure approach
const createMouseHandlers = useCallback(() => {
let currentResizeParams: ResizeParams | null = null;
const handleMouseMove = (event: MouseEvent) => {
const imageWrapper = imageWrapperRef.current;
if (!isDefined(currentResizeParams) || !isDefined(imageWrapper)) {
return;
}
const deltaX = event.clientX - currentResizeParams.initialClientX;
const { initialWidth, handleUsed } = currentResizeParams;
let newWidth =
align === 'center'
? initialWidth + (handleUsed === 'left' ? -deltaX * 2 : deltaX * 2)
: initialWidth + (handleUsed === 'left' ? -deltaX : deltaX);
const maxWidth =
editor.view.dom.firstElementChild?.clientWidth || IMAGE_MAX_WIDTH;
newWidth = Math.min(Math.max(newWidth, IMAGE_MIN_WIDTH), maxWidth);
setWidth(newWidth);
imageWrapper.style.width = `${newWidth}px`;
};
const handleMouseUp = (event: MouseEvent) => {
const imageWrapper = imageWrapperRef.current;
if (!isDefined(imageWrapper) || !isDefined(currentResizeParams)) {
return;
}
if (
(!event.target || !imageWrapper.contains(event.target as Node)) &&
isHovering
) {
setIsHovering(false);
return;
}
const finalWidth = imageWrapper.clientWidth;
currentResizeParams = null;
setResizeParams(null);
updateAttributes({ width: finalWidth });
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
const startResize = (resizeParams: ResizeParams) => {
currentResizeParams = resizeParams;
setResizeParams(resizeParams);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
};
return { startResize };
}, [editor, align, isHovering, updateAttributes]);
const { startResize } = createMouseHandlers();
const handleImageHandleMouseDown = useCallback(
(handle: 'left' | 'right', event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
const resizeParams = {
initialWidth: imageWrapperRef.current?.clientWidth ?? IMAGE_MAX_WIDTH,
initialClientX: event.clientX,
handleUsed: handle,
};
startResize(resizeParams);
},
[startResize],
);
const handleImageHover = useCallback(() => {
if (!editor.isEditable) {
return;
}
setIsHovering(true);
}, [editor.isEditable]);
const handleImageHoverEnd = useCallback(() => {
setIsHovering(false);
}, []);
return (
<StyledNodeViewWrapper
onMouseEnter={handleImageHover}
onMouseLeave={handleImageHoverEnd}
align={align}
>
<StyledImageWrapper
ref={imageWrapperRef}
style={{ width: width ? `${width}px` : 'fit-content' }}
>
<StyledImageContainer>
<StyledImage
src={src}
alt={alt}
draggable={false}
contentEditable={false}
/>
{/* Show resize handles when hovering over image OR actively resizing */}
{(isHovering || isDefined(resizeParams)) && (
<>
<StyledImageHandle
handle="left"
onMouseDown={(e) => handleImageHandleMouseDown('left', e)}
/>
<StyledImageHandle
handle="right"
onMouseDown={(e) => handleImageHandleMouseDown('right', e)}
/>
</>
)}
</StyledImageContainer>
</StyledImageWrapper>
</StyledNodeViewWrapper>
);
};

View file

@ -0,0 +1,61 @@
import {
UploadImagePlugin,
type UploadImagePluginProps,
} from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImagePlugin';
import { Extension } from '@tiptap/core';
type UploadImageOptions = Omit<UploadImagePluginProps, 'editor'> & {};
type UploadImageStorage = {
placeholderImages: Set<string>;
};
declare module '@tiptap/core' {
interface Storage {
uploadImage: UploadImageStorage;
}
}
export const UploadImageExtension = Extension.create<
UploadImageOptions,
UploadImageStorage
>({
name: 'uploadImage',
addOptions: () => {
return {
allowedMimeTypes: [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
],
onImageUpload: undefined,
onImageUploadError: undefined,
};
},
addStorage: () => {
return {
placeholderImages: new Set(),
};
},
addProseMirrorPlugins() {
const { onImageUpload } = this.options;
if (!onImageUpload) {
return [];
}
return [
UploadImagePlugin({
editor: this.editor,
allowedMimeTypes: this.options.allowedMimeTypes,
onImageUpload: this.options.onImageUpload,
onImageUploadError: this.options.onImageUploadError,
}),
];
},
});

View file

@ -0,0 +1,128 @@
import { type Editor } from '@tiptap/core';
import { type Node } from '@tiptap/pm/model';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { type EditorView } from '@tiptap/pm/view';
export type UploadImagePluginProps = {
editor: Editor;
allowedMimeTypes?: string[];
onImageUpload?: (file: File) => Promise<string>;
onImageUploadError?: (error: Error, file: File) => void;
};
export const UploadImagePlugin = (options: UploadImagePluginProps) => {
const { editor, onImageUpload, allowedMimeTypes, onImageUploadError } =
options;
const handleImageUpload = (view: EditorView, file: File, pos?: number) => {
const placeholderSrc = URL.createObjectURL(file);
const { tr, schema } = view.state;
const imageNode = schema.nodes.image.create({
src: placeholderSrc,
alt: file.name,
});
editor.extensionStorage.uploadImage.placeholderImages.add(placeholderSrc);
const resolvedPos =
pos !== undefined
? view.state.doc.resolve(pos)
: view.state.selection.$head;
const transaction = tr.insert(resolvedPos.pos, imageNode);
view.dispatch(transaction);
onImageUpload?.(file)
.then((uploadedSrc) => {
const updateTr = view.state.tr;
const predicate = (node: Node) =>
node.type.name === 'image' && node.attrs.src === placeholderSrc;
view.state.doc.descendants((node, pos) => {
if (predicate(node)) {
updateTr.setNodeMarkup(pos, undefined, {
...node.attrs,
src: uploadedSrc,
});
return false;
}
});
view.dispatch(updateTr);
})
.catch((error: Error) => {
const removeTr = view.state.tr;
const predicate = (node: Node) =>
node.type.name === 'image' && node.attrs.src === placeholderSrc;
view.state.doc.descendants((node, pos) => {
if (predicate(node)) {
removeTr.delete(pos, pos + node.nodeSize);
return false; // Stop traversal after finding the target
}
});
view.dispatch(removeTr);
onImageUploadError?.(error, file);
})
.finally(() => {
editor.extensionStorage.uploadImage.placeholderImages.delete(
placeholderSrc,
);
URL.revokeObjectURL(placeholderSrc);
});
};
return new Plugin({
key: new PluginKey('uploadImage'),
props: {
handleDrop: (view, event) => {
if (!onImageUpload || !event.dataTransfer?.files?.length) {
return false;
}
const images = Array.from(event.dataTransfer.files).filter((file) =>
allowedMimeTypes?.includes(file.type),
);
if (images.length === 0) {
return false;
}
const pos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!pos) {
return false;
}
event.preventDefault();
event.stopPropagation();
images.forEach((file) => handleImageUpload(view, file, pos.pos));
return true;
},
handlePaste: (view, event) => {
if (!onImageUpload || !event.clipboardData?.files?.length) {
return false;
}
const images = Array.from(event.clipboardData.files).filter((file) =>
allowedMimeTypes?.includes(file.type),
);
if (images.length === 0) {
return false;
}
event.preventDefault();
event.stopPropagation();
images.forEach((file) => handleImageUpload(view, file));
return true;
},
},
});
};

View file

@ -0,0 +1,103 @@
import { ResizableImage } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImage';
import { UploadImageExtension } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImageExtension';
import { getInitialEmailEditorContent } from '@/workflow/workflow-variables/utils/getInitialEmailEditorContent';
import { VariableTag } from '@/workflow/workflow-variables/utils/variableTag';
import { Bold } from '@tiptap/extension-bold';
import { Document } from '@tiptap/extension-document';
import { HardBreak } from '@tiptap/extension-hard-break';
import { Heading } from '@tiptap/extension-heading';
import { Italic } from '@tiptap/extension-italic';
import { Link } from '@tiptap/extension-link';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Strike } from '@tiptap/extension-strike';
import { Text } from '@tiptap/extension-text';
import { Underline } from '@tiptap/extension-underline';
import { Dropcursor, Placeholder, UndoRedo } from '@tiptap/extensions';
import { type Editor, useEditor } from '@tiptap/react';
import { type DependencyList, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
type UseEmailEditorProps = {
placeholder: string | undefined;
readonly: boolean | undefined;
defaultValue: string | undefined | null;
onUpdate: (editor: Editor) => void;
onFocus?: (editor: Editor) => void;
onBlur?: (editor: Editor) => void;
onImageUpload?: (file: File) => Promise<string>;
onImageUploadError?: (error: Error, file: File) => void;
};
export const useEmailEditor = (
{
placeholder,
readonly,
defaultValue,
onUpdate,
onFocus,
onBlur,
onImageUpload,
onImageUploadError,
}: UseEmailEditorProps,
dependencies?: DependencyList,
) => {
const extensions = useMemo(
() => [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder,
}),
VariableTag,
HardBreak.configure({
keepMarks: false,
}),
UndoRedo,
Bold,
Italic,
Strike,
Underline,
Heading.configure({
levels: [1, 2, 3],
}),
Link.configure({
openOnClick: false,
}),
ResizableImage,
Dropcursor,
UploadImageExtension.configure({
onImageUpload,
onImageUploadError,
}),
],
[placeholder, onImageUpload, onImageUploadError],
);
const editor = useEditor(
{
extensions,
content: isDefined(defaultValue)
? getInitialEmailEditorContent(defaultValue)
: undefined,
editable: !readonly,
onUpdate: ({ editor }) => {
onUpdate(editor);
},
onFocus: ({ editor }) => {
onFocus?.(editor);
},
onBlur: ({ editor }) => {
onBlur?.(editor);
},
editorProps: {
scrollThreshold: 60,
scrollMargin: 60,
},
injectCSS: false,
},
dependencies,
);
return editor;
};

View file

@ -0,0 +1,20 @@
import { type Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
export const useTextBubbleState = (editor: Editor) => {
const state = useEditorState({
editor,
selector: (ctx) => {
return {
isBold: ctx.editor.isActive('bold'),
isItalic: ctx.editor.isActive('italic'),
isStrike: ctx.editor.isActive('strike'),
isUnderline: ctx.editor.isActive('underline'),
isLink: ctx.editor.isActive('link'),
linkHref: ctx.editor.getAttributes('link').href || '',
};
},
});
return state;
};

View file

@ -0,0 +1,81 @@
import { type Editor, useEditorState } from '@tiptap/react';
import {
type IconComponent,
IconH1,
IconH2,
IconH3,
IconPilcrow,
} from 'twenty-ui/display';
export type TurnIntoBlockOptions = {
title: string;
id: string;
disabled: () => boolean;
isActive: () => boolean;
onClick: () => void;
icon: IconComponent;
};
export const useTurnIntoBlockOptions = (editor: Editor) => {
return useEditorState({
editor,
selector: ({ editor }): TurnIntoBlockOptions[] => [
{
id: 'paragraph',
title: 'Paragraph',
icon: IconPilcrow,
onClick: () => {
return editor.chain().focus().setParagraph().run();
},
disabled: () => {
return !editor.can().setParagraph();
},
isActive: () => {
return editor.isActive('paragraph');
},
},
{
id: 'heading1',
title: 'Heading 1',
icon: IconH1,
onClick: () => {
return editor.chain().focus().setHeading({ level: 1 }).run();
},
disabled: () => {
return !editor.can().setHeading({ level: 1 });
},
isActive: () => {
return editor.isActive('heading', { level: 1 });
},
},
{
id: 'heading2',
title: 'Heading 2',
icon: IconH2,
onClick: () => {
return editor.chain().focus().setHeading({ level: 2 }).run();
},
disabled: () => {
return !editor.can().setHeading({ level: 2 });
},
isActive: () => {
return editor.isActive('heading', { level: 2 });
},
},
{
id: 'heading3',
title: 'Heading 3',
icon: IconH3,
onClick: () => {
return editor.chain().focus().setHeading({ level: 3 }).run();
},
disabled: () => {
return !editor.can().setHeading({ level: 3 });
},
isActive: () => {
return editor.isActive('heading', { level: 3 });
},
},
],
});
};

View file

@ -0,0 +1,27 @@
import { type Editor, isTextSelection } from '@tiptap/core';
type IsTextSelectedProps = {
editor: Editor;
};
export const isTextSelected = ({ editor }: IsTextSelectedProps) => {
const {
state: {
doc,
selection,
selection: { empty, from, to },
},
} = editor;
// Sometime check for `empty` is not enough.
// Doubleclick an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock =
!doc.textBetween(from, to).length && isTextSelection(selection);
if (empty || isEmptyTextBlock || !editor.isEditable) {
return false;
}
return true;
};

View file

@ -109,6 +109,7 @@ export const WorkflowVariablesDropdown = ({
return (
<Dropdown
dropdownId={dropdownId}
isDropdownInModal={true}
clickableComponent={
clickableComponent ?? (
<StyledDropdownVariableButtonContainer

View file

@ -0,0 +1,31 @@
import { getInitialEditorContent } from '@/workflow/workflow-variables/utils/getInitialEditorContent';
import type { JSONContent } from '@tiptap/react';
import { logError } from '~/utils/logError';
// Previous format of the email body was plain text,
// but from now on we will save it as JSON.
// So it will fail to parse the content, that's why we have this fallback.
export const getInitialEmailEditorContent = (
rawContent: string,
): JSONContent => {
// Handle empty or null content
if (!rawContent || rawContent.trim() === '') {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: [],
},
],
};
}
try {
const json = JSON.parse(rawContent);
return json;
} catch (error) {
logError(error);
return getInitialEditorContent(rawContent);
}
};

View file

@ -69,8 +69,8 @@
"@ptc-org/nestjs-query-core": "4.4.0",
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch",
"@ptc-org/nestjs-query-typeorm": "4.2.1-alpha.2",
"@react-email/components": "0.0.35",
"@react-email/render": "0.0.17",
"@react-email/components": "^0.5.3",
"@react-email/render": "^1.2.3",
"@revertdotdev/revert-react": "^0.0.21",
"@sentry/nestjs": "^8.55.0",
"@sentry/node": "9.26.0",

View file

@ -80,8 +80,8 @@ export class ApprovedAccessDomainService {
serverUrl: this.twentyConfigService.get('SERVER_URL'),
locale: sender.locale,
});
const html = render(emailTemplate);
const text = render(emailTemplate, {
const html = await render(emailTemplate);
const text = await render(emailTemplate, {
plainText: true,
});

View file

@ -18,6 +18,17 @@ import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-membe
import { ApprovedAccessDomainService } from './approved-access-domain.service';
// To avoid dynamic import issues in Jest
jest.mock('@react-email/render', () => ({
render: jest.fn().mockImplementation(async (template, options) => {
if (options?.plainText) {
return 'Plain Text Email';
}
return '<html><body>HTML email content</body></html>';
}),
}));
describe('ApprovedAccessDomainService', () => {
let service: ApprovedAccessDomainService;
let approvedAccessDomainRepository: Repository<ApprovedAccessDomain>;

View file

@ -482,8 +482,8 @@ export class AuthService {
locale: firstUserWorkspace.locale,
});
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const passwordChangedMsg = msg`Your Password Has Been Successfully Changed`;
const i18n = this.i18nService.getI18nInstance(firstUserWorkspace.locale);

View file

@ -18,6 +18,17 @@ import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { ResetPasswordService } from './reset-password.service';
// To avoid dynamic import issues in Jest
jest.mock('@react-email/render', () => ({
render: jest.fn().mockImplementation(async (template, options) => {
if (options?.plainText) {
return 'Plain Text Email';
}
return '<html><body>HTML email content</body></html>';
}),
}));
describe('ResetPasswordService', () => {
let service: ResetPasswordService;
let userRepository: Repository<User>;

View file

@ -163,8 +163,8 @@ export class ResetPasswordService {
const emailTemplate = PasswordResetLinkEmail(emailData);
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const resetPasswordMsg = msg`Action Needed to Reset Password`;
const i18n = this.i18nService.getI18nInstance(locale);

View file

@ -80,8 +80,8 @@ export class EmailVerificationService {
const emailTemplate = SendEmailVerificationLinkEmail(emailData);
const html = render(emailTemplate);
const text = render(emailTemplate, {
const html = await render(emailTemplate);
const text = await render(emailTemplate, {
plainText: true,
});

View file

@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { render, toPlainText } from '@react-email/render';
import DOMPurify from 'dompurify';
import { reactMarkupFromJSON } from 'twenty-emails';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { z } from 'zod';
@ -16,6 +18,7 @@ import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/s
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
import { parseEmailBody } from 'src/utils/parse-email-body';
@Injectable()
export class SendEmailTool implements Tool {
@ -115,17 +118,23 @@ export class SendEmailTool implements Tool {
workspaceId,
);
const parsedBody = parseEmailBody(body);
const reactMarkup = reactMarkupFromJSON(parsedBody);
const htmlBody = await render(reactMarkup);
const textBody = toPlainText(htmlBody);
const { JSDOM } = await import('jsdom');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const safeBody = purify.sanitize(body || '');
const safeHtmlBody = purify.sanitize(htmlBody || '');
const safeSubject = purify.sanitize(subject || '');
await this.sendMessageService.sendMessage(
{
to: email,
subject: safeSubject,
body: safeBody,
body: textBody,
html: safeHtmlBody,
},
connectedAccount,
);

View file

@ -23,6 +23,17 @@ import { WorkspaceInvitationService } from './workspace-invitation.service';
// To fix a circular dependency issue
jest.mock('src/engine/core-modules/workspace/services/workspace.service');
// To avoid dynamic import issues in Jest
jest.mock('@react-email/render', () => ({
render: jest.fn().mockImplementation(async (template, options) => {
if (options?.plainText) {
return 'Plain Text Email';
}
return '<html><body>HTML email content</body></html>';
}),
}));
describe('WorkspaceInvitationService', () => {
let service: WorkspaceInvitationService;
let appTokenRepository: Repository<AppToken>;

View file

@ -305,8 +305,8 @@ export class WorkspaceInvitationService {
};
const emailTemplate = SendInviteLinkEmail(emailData);
const html = render(emailTemplate);
const text = render(emailTemplate, {
const html = await render(emailTemplate);
const text = await render(emailTemplate, {
plainText: true,
});

View file

@ -123,8 +123,8 @@ export class CleanerWorkspaceService {
locale: workspaceMember.locale,
};
const emailTemplate = WarnSuspendedWorkspaceEmail(emailData);
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const workspaceDeletionMsg = msg`Action needed to prevent workspace deletion`;
const i18n = this.i18nService.getI18nInstance(workspaceMember.locale);
@ -201,8 +201,8 @@ export class CleanerWorkspaceService {
locale: workspaceMember.locale,
};
const emailTemplate = CleanSuspendedWorkspaceEmail(emailData);
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
this.emailService.send({
to: workspaceMember.userEmail,

View file

@ -41,6 +41,7 @@ export const ATTACHMENT_STANDARD_FIELD_IDS = {
company: '20202020-ceab-4a28-b546-73b06b4c08d5',
opportunity: '20202020-7374-499d-bea3-9354890755b5',
dashboard: '20202020-5324-43f3-9dbf-1a33e7de0ce6',
workflow: '20202020-f1e8-4c9d-8a7b-3f5e1d2c9a8b',
custom: '20202020-302d-43b3-9aea-aa4f89282a9f',
} as const;
@ -464,6 +465,7 @@ export const WORKFLOW_STANDARD_FIELD_IDS = {
automatedTriggers: '20202020-3319-4234-a34c-117ecad2b8a9',
favorites: '20202020-c554-4c41-be7a-cf9cd4b0d512',
timelineActivities: '20202020-906e-486a-a798-131a5f081faf',
attachments: '20202020-4a8c-4e2d-9b1c-7e5f3a2b4c6d',
createdBy: '20202020-6007-401a-8aa5-e6f48581a6f3',
searchVector: '20202020-535d-4ffa-b7f3-4fa0d5da1b7a',
} as const;

View file

@ -25,6 +25,7 @@ import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.work
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { TaskWorkspaceEntity } from 'src/modules/task/standard-objects/task.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@WorkspaceEntity({
@ -183,6 +184,22 @@ export class AttachmentWorkspaceEntity extends BaseWorkspaceEntity {
})
dashboardId: string | null;
@WorkspaceRelation({
standardId: ATTACHMENT_STANDARD_FIELD_IDS.workflow,
type: RelationType.MANY_TO_ONE,
label: msg`Workflow`,
description: msg`Attachment workflow`,
icon: 'IconSettingsAutomation',
inverseSideTarget: () => WorkflowWorkspaceEntity,
inverseSideFieldKey: 'attachments',
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsNullable()
workflow: Relation<WorkflowWorkspaceEntity> | null;
@WorkspaceJoinColumn('workflow')
workflowId: string | null;
@WorkspaceDynamicRelation({
type: RelationType.MANY_TO_ONE,
argsFactory: (oppositeObjectMetadata) => ({

View file

@ -0,0 +1,147 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/oauth2-client.provider';
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
describe('MessagingSendMessageService - Gmail HTML Support', () => {
let service: MessagingSendMessageService;
let gmailClientProvider: jest.Mocked<GmailClientProvider>;
let oAuth2ClientProvider: jest.Mocked<OAuth2ClientProvider>;
beforeEach(async () => {
const mockGmailClient = {
users: {
messages: {
send: jest.fn().mockResolvedValue({ data: { id: 'message-id' } }),
},
},
};
const mockOAuth2Client = {
userinfo: {
get: jest.fn().mockResolvedValue({
data: { email: 'test@example.com', name: 'Test User' },
}),
},
};
const module: TestingModule = await Test.createTestingModule({
providers: [
MessagingSendMessageService,
{
provide: GmailClientProvider,
useValue: {
getGmailClient: jest.fn().mockResolvedValue(mockGmailClient),
},
},
{
provide: OAuth2ClientProvider,
useValue: {
getOAuth2Client: jest.fn().mockResolvedValue(mockOAuth2Client),
},
},
{
provide: MicrosoftClientProvider,
useValue: {},
},
{
provide: SmtpClientProvider,
useValue: {},
},
],
}).compile();
service = module.get<MessagingSendMessageService>(
MessagingSendMessageService,
);
gmailClientProvider = module.get(GmailClientProvider);
oAuth2ClientProvider = module.get(OAuth2ClientProvider);
});
it('should send multipart/alternative email with both text and HTML parts via Gmail', async () => {
const sendMessageInput = {
to: 'recipient@example.com',
subject: 'Test HTML Email',
body: 'This is plain text content',
html: '<p>This is <strong>HTML</strong> content</p>',
};
const connectedAccount = {
provider: ConnectedAccountProvider.GOOGLE,
accessToken: 'access-token',
refreshToken: 'refresh-token',
} as any;
await service.sendMessage(sendMessageInput, connectedAccount);
const gmailClient =
await gmailClientProvider.getGmailClient(connectedAccount);
const sendCall = gmailClient.users.messages.send as jest.Mock;
expect(sendCall).toHaveBeenCalledTimes(1);
const sentMessage = sendCall.mock.calls[0][0];
const rawMessage = Buffer.from(
sentMessage.requestBody.raw,
'base64',
).toString();
expect(rawMessage).toContain('MIME-Version: 1.0');
expect(rawMessage).toContain(
'Content-Type: multipart/alternative; boundary=',
);
expect(rawMessage).toContain('Content-Type: text/plain; charset="UTF-8"');
expect(rawMessage).toContain('Content-Type: text/html; charset="UTF-8"');
expect(rawMessage).toContain('This is plain text content');
expect(rawMessage).toContain(
'<p>This is <strong>HTML</strong> content</p>',
);
expect(rawMessage).toContain('To: recipient@example.com');
expect(rawMessage).toContain('Subject:');
});
it('should handle missing fromName gracefully', async () => {
const mockOAuth2ClientNoName = {
userinfo: {
get: jest.fn().mockResolvedValue({
data: { email: 'test@example.com' }, // No name field
}),
},
};
(oAuth2ClientProvider.getOAuth2Client as jest.Mock).mockResolvedValueOnce(
mockOAuth2ClientNoName,
);
const sendMessageInput = {
to: 'recipient@example.com',
subject: 'Test Email',
body: 'Plain text',
html: '<p>HTML content</p>',
};
const connectedAccount = {
provider: ConnectedAccountProvider.GOOGLE,
accessToken: 'access-token',
refreshToken: 'refresh-token',
} as any;
await service.sendMessage(sendMessageInput, connectedAccount);
const gmailClient =
await gmailClientProvider.getGmailClient(connectedAccount);
const sendCall = gmailClient.users.messages.send as jest.Mock;
const rawMessage = Buffer.from(
sendCall.mock.calls[0][0].requestBody.raw,
'base64',
).toString();
expect(rawMessage).toContain('From: test@example.com');
expect(rawMessage).not.toContain('""');
});
});

View file

@ -12,14 +12,15 @@ import {
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/oauth2-client.provider';
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { isAccessTokenRefreshingError } from 'src/modules/messaging/message-import-manager/drivers/microsoft/utils/is-access-token-refreshing-error.utils';
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';
interface SendMessageInput {
body: string;
subject: string;
to: string;
html: string;
}
@Injectable()
@ -46,26 +47,37 @@ export class MessagingSendMessageService {
const { data } = await oAuth2Client.userinfo.get();
const fromEmail = data.email;
const fromName = data.name;
const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const headers: string[] = [];
if (isDefined(fromName)) {
headers.push(`From: "${mimeEncode(fromName)}" <${fromEmail}>`);
} else {
headers.push(`From: ${fromEmail}`);
}
headers.push(
`To: ${sendMessageInput.to}`,
`Subject: ${mimeEncode(sendMessageInput.subject)}`,
'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/plain; charset="UTF-8"',
'',
sendMessageInput.body,
'',
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'',
sendMessageInput.html,
'',
`--${boundary}--`,
);
const message = headers.join('\n');
const encodedMessage = Buffer.from(message).toString('base64');
await gmailClient.users.messages.send({
@ -85,8 +97,8 @@ export class MessagingSendMessageService {
const message = {
subject: sendMessageInput.subject,
body: {
contentType: 'Text',
content: sendMessageInput.body,
contentType: 'HTML',
content: sendMessageInput.html,
},
toRecipients: [{ emailAddress: { address: sendMessageInput.to } }],
};
@ -130,6 +142,7 @@ export class MessagingSendMessageService {
to: sendMessageInput.to,
subject: sendMessageInput.subject,
text: sendMessageInput.body,
html: sendMessageInput.html,
});
break;
}

View file

@ -0,0 +1,61 @@
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
describe('Gmail MIME Message Format', () => {
it('should create valid multipart/alternative MIME structure', () => {
const sendMessageInput = {
to: 'test@example.com',
subject: 'Test Subject',
body: 'Plain text content',
html: '<p>HTML content</p>',
};
const fromEmail = 'sender@example.com';
const fromName = 'Test Sender';
const boundary = 'boundary_test_123';
const headers: string[] = [];
headers.push(`From: "${mimeEncode(fromName)}" <${fromEmail}>`);
headers.push(
`To: ${sendMessageInput.to}`,
`Subject: ${mimeEncode(sendMessageInput.subject)}`,
'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/plain; charset="UTF-8"',
'',
sendMessageInput.body,
'',
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'',
sendMessageInput.html,
'',
`--${boundary}--`,
);
const message = headers.join('\n');
expect(message).toContain('MIME-Version: 1.0');
expect(message).toContain('Content-Type: multipart/alternative');
expect(message).toContain('Content-Type: text/plain; charset="UTF-8"');
expect(message).toContain('Content-Type: text/html; charset="UTF-8"');
expect(message).toContain('Plain text content');
expect(message).toContain('<p>HTML content</p>');
expect(message).toContain(`--${boundary}`);
expect(message).toContain(`--${boundary}--`);
});
it('should handle missing fromName gracefully', () => {
const fromEmail = 'sender@example.com';
const headers: string[] = [];
headers.push(`From: ${fromEmail}`);
const message = headers.join('\n');
expect(message).toContain(`From: ${fromEmail}`);
expect(message).not.toContain('""');
});
});

View file

@ -24,6 +24,7 @@ import {
type FieldTypeAndNameMetadata,
getTsVectorColumnExpressionFromFields,
} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
import { WorkflowAutomatedTriggerWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity';
@ -193,6 +194,18 @@ export class WorkflowWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsSystem()
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: WORKFLOW_STANDARD_FIELD_IDS.attachments,
type: RelationType.ONE_TO_MANY,
label: msg`Attachments`,
description: msg`Attachments linked to the workflow`,
icon: 'IconFileUpload',
inverseSideTarget: () => AttachmentWorkspaceEntity,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsSystem()
attachments: Relation<AttachmentWorkspaceEntity[]>;
@WorkspaceField({
standardId: WORKFLOW_STANDARD_FIELD_IDS.createdBy,
type: FieldMetadataType.ACTOR,

View file

@ -0,0 +1,11 @@
import { type JSONContent } from 'twenty-emails';
export const parseEmailBody = (body: string): JSONContent | string => {
try {
const json = JSON.parse(body);
return json;
} catch {
return body;
}
};

View file

@ -34,6 +34,17 @@ export { safeParseRelativeDateFilterValue } from './safeParseRelativeDateFilterV
export { getGenericOperationName } from './sentry/getGenericOperationName';
export { getHumanReadableNameFromCode } from './sentry/getHumanReadableNameFromCode';
export { capitalize } from './strings/capitalize';
export type {
TipTapMarkType,
TipTapNodeType,
LinkMarkAttributes,
TipTapMark,
} from './tiptap/tiptap-marks';
export {
TIPTAP_MARK_TYPES,
TIPTAP_NODE_TYPES,
TIPTAP_MARKS_RENDER_ORDER,
} from './tiptap/tiptap-marks';
export type { StringPropertyKeys } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties';
export { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties';
export { trimAndRemoveDuplicatedWhitespacesFromString } from './trim-and-remove-duplicated-whitespaces-from-string';

View file

@ -0,0 +1 @@
export * from './tiptap-marks';

View file

@ -0,0 +1,42 @@
// Shared TipTap types for consistency between frontend and email renderer
export const TIPTAP_MARK_TYPES = {
BOLD: 'bold',
ITALIC: 'italic',
UNDERLINE: 'underline',
STRIKE: 'strike',
LINK: 'link',
} as const;
export const TIPTAP_NODE_TYPES = {
PARAGRAPH: 'paragraph',
TEXT: 'text',
HEADING: 'heading',
VARIABLE_TAG: 'variableTag',
IMAGE: 'image',
} as const;
export type TipTapMarkType = typeof TIPTAP_MARK_TYPES[keyof typeof TIPTAP_MARK_TYPES];
export type TipTapNodeType = typeof TIPTAP_NODE_TYPES[keyof typeof TIPTAP_NODE_TYPES];
// Order for mark rendering (inner to outer)
export const TIPTAP_MARKS_RENDER_ORDER: readonly TipTapMarkType[] = [
TIPTAP_MARK_TYPES.UNDERLINE,
TIPTAP_MARK_TYPES.BOLD,
TIPTAP_MARK_TYPES.ITALIC,
TIPTAP_MARK_TYPES.STRIKE,
TIPTAP_MARK_TYPES.LINK,
] as const;
// Link mark attributes interface
export interface LinkMarkAttributes {
href?: string;
target?: string;
rel?: string;
}
// Generic mark interface
export interface TipTapMark {
type: TipTapMarkType;
attrs?: LinkMarkAttributes | Record<string, unknown>;
}

View file

@ -3,6 +3,9 @@ export {
IconNumber123 as Icon123,
IconAlertCircle,
IconAlertTriangle,
IconAlignCenter,
IconAlignLeft,
IconAlignRight,
IconApi,
IconApps,
IconAppWindow,
@ -21,6 +24,7 @@ export {
IconBaselineDensitySmall,
IconBell,
IconBlockquote,
IconBold,
IconBolt,
IconBook2,
IconBookmark,
@ -42,11 +46,11 @@ export {
IconCalendar,
IconCalendarDue,
IconCalendarEvent,
IconCalendarMonth,
IconCalendarRepeat,
IconCalendarTime,
IconCalendarX,
IconCalendarMonth,
IconCalendarWeek,
IconCalendarX,
IconChartBar,
IconChartCandle,
IconChartDots3,
@ -190,6 +194,7 @@ export {
IconId,
IconInbox,
IconInfoCircle,
IconItalic,
IconJson,
IconKey,
IconLanguage,
@ -305,6 +310,7 @@ export {
IconSquareRoundedX,
IconStatusChange,
IconStepInto,
IconStrikethrough,
IconSun,
IconSunMoon,
IconSwitchHorizontal,
@ -326,6 +332,7 @@ export {
IconTrendingDown,
IconTrendingUp,
IconTypography,
IconUnderline,
IconUnlink,
IconUpload,
IconUser,

View file

@ -65,6 +65,9 @@ export {
Icon123,
IconAlertCircle,
IconAlertTriangle,
IconAlignCenter,
IconAlignLeft,
IconAlignRight,
IconApi,
IconApps,
IconAppWindow,
@ -83,6 +86,7 @@ export {
IconBaselineDensitySmall,
IconBell,
IconBlockquote,
IconBold,
IconBolt,
IconBook2,
IconBookmark,
@ -104,11 +108,11 @@ export {
IconCalendar,
IconCalendarDue,
IconCalendarEvent,
IconCalendarMonth,
IconCalendarRepeat,
IconCalendarTime,
IconCalendarX,
IconCalendarMonth,
IconCalendarWeek,
IconCalendarX,
IconChartBar,
IconChartCandle,
IconChartDots3,
@ -252,6 +256,7 @@ export {
IconId,
IconInbox,
IconInfoCircle,
IconItalic,
IconJson,
IconKey,
IconLanguage,
@ -367,6 +372,7 @@ export {
IconSquareRoundedX,
IconStatusChange,
IconStepInto,
IconStrikethrough,
IconSun,
IconSunMoon,
IconSwitchHorizontal,
@ -388,6 +394,7 @@ export {
IconTrendingDown,
IconTrendingUp,
IconTypography,
IconUnderline,
IconUnlink,
IconUpload,
IconUser,

View file

@ -27,7 +27,8 @@ const StyledEditorLoader = styled.div<{
}>`
align-items: center;
display: flex;
height: ${({ height }) => height}px;
height: ${({ height }) =>
typeof height === 'number' ? `${height}px` : height};
justify-content: center;
border: 1px solid ${({ theme }) => theme.border.color.medium};
background-color: ${({ theme }) => theme.background.transparent.lighter};

292
yarn.lock
View file

@ -11209,13 +11209,6 @@ __metadata:
languageName: node
linkType: hard
"@one-ini/wasm@npm:0.1.1":
version: 0.1.1
resolution: "@one-ini/wasm@npm:0.1.1"
checksum: 10c0/54700e055037f1a63bfcc86d24822203b25759598c2c3e295d1435130a449108aebc119c9c2e467744767dbe0b6ab47a182c61aa1071ba7368f5e20ab197ba65
languageName: node
linkType: hard
"@open-draft/deferred-promise@npm:^2.1.0, @open-draft/deferred-promise@npm:^2.2.0":
version: 2.2.0
resolution: "@open-draft/deferred-promise@npm:2.2.0"
@ -15022,32 +15015,32 @@ __metadata:
languageName: node
linkType: hard
"@react-email/body@npm:0.0.11":
version: 0.0.11
resolution: "@react-email/body@npm:0.0.11"
"@react-email/body@npm:0.1.0":
version: 0.1.0
resolution: "@react-email/body@npm:0.1.0"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/4a86ea8041bf94992acde12efd79cd91f980b3c40a86792f98bd546e0b49f19fbdca1fc87d5d3e472a70535a3195f2738bbec1f9dec862d48c4bc625f599d3d6
checksum: 10c0/deeee52e0326eed20b3267dfe970649705298efae9ff580009ee7f48c201cbca6cd200be163865178ccdab04fc8e44384c6dd7ab130557b93f421d3b263b33c8
languageName: node
linkType: hard
"@react-email/button@npm:0.0.19":
version: 0.0.19
resolution: "@react-email/button@npm:0.0.19"
"@react-email/button@npm:0.2.0":
version: 0.2.0
resolution: "@react-email/button@npm:0.2.0"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/e96fe15cb4307c36add3ebaf382cf5f408c29fbbc433ef09b347a4d6488eecdaa23424880f8bdad18f773c91e8c0b2d24ec74c9010608f55100c5625c9c0be56
checksum: 10c0/1145d096ace8a459271f550faa941668122bd816adc6547390ede5becab5b0dc99a6f19c838e539fe53a73953bd4a4de790b1a7ad3d5981c44f5d34149045bfc
languageName: node
linkType: hard
"@react-email/code-block@npm:0.0.11":
version: 0.0.11
resolution: "@react-email/code-block@npm:0.0.11"
"@react-email/code-block@npm:0.1.0":
version: 0.1.0
resolution: "@react-email/code-block@npm:0.1.0"
dependencies:
prismjs: "npm:1.29.0"
prismjs: "npm:^1.30.0"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/98a45460620c887a8adf5c59a936dd9fa2df76101b00b246df90ffe9108158262715b00401018ba6ba22f10dfad46b7e9b71cefa3c672728deee0a18c1f13293
checksum: 10c0/55d84654cbf593e49102a11d1615e3beefb6943a82842ab1e51f435c6130a62f4ea2ebc034b127ebcc96822789b58cf352df340823278c9759bb3f031d8a796e
languageName: node
linkType: hard
@ -15069,13 +15062,13 @@ __metadata:
languageName: node
linkType: hard
"@react-email/components@npm:0.0.35":
version: 0.0.35
resolution: "@react-email/components@npm:0.0.35"
"@react-email/components@npm:^0.5.3":
version: 0.5.3
resolution: "@react-email/components@npm:0.5.3"
dependencies:
"@react-email/body": "npm:0.0.11"
"@react-email/button": "npm:0.0.19"
"@react-email/code-block": "npm:0.0.11"
"@react-email/body": "npm:0.1.0"
"@react-email/button": "npm:0.2.0"
"@react-email/code-block": "npm:0.1.0"
"@react-email/code-inline": "npm:0.0.5"
"@react-email/column": "npm:0.0.13"
"@react-email/container": "npm:0.0.15"
@ -15086,16 +15079,16 @@ __metadata:
"@react-email/html": "npm:0.0.11"
"@react-email/img": "npm:0.0.11"
"@react-email/link": "npm:0.0.12"
"@react-email/markdown": "npm:0.0.14"
"@react-email/preview": "npm:0.0.12"
"@react-email/render": "npm:1.0.5"
"@react-email/markdown": "npm:0.0.15"
"@react-email/preview": "npm:0.0.13"
"@react-email/render": "npm:1.2.3"
"@react-email/row": "npm:0.0.12"
"@react-email/section": "npm:0.0.16"
"@react-email/tailwind": "npm:1.0.4"
"@react-email/text": "npm:0.1.1"
"@react-email/tailwind": "npm:1.2.2"
"@react-email/text": "npm:0.1.5"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/b227f9d06e414334bbe5fb86919f8c48f0440532a3c9dd3272899e1fb86595eead70ab79407d3325f55de3707f57f863b6bee253415b887012d2454d2016d552
checksum: 10c0/a0839c635cde48d45d49d3ea19aa17a8e5616225e516d76de1c4d0915d88b903241033a55f4aa047d212672e48ff809840e0d05afd80bbaacb330cef7804971a
languageName: node
linkType: hard
@ -15171,51 +15164,37 @@ __metadata:
languageName: node
linkType: hard
"@react-email/markdown@npm:0.0.14":
version: 0.0.14
resolution: "@react-email/markdown@npm:0.0.14"
"@react-email/markdown@npm:0.0.15":
version: 0.0.15
resolution: "@react-email/markdown@npm:0.0.15"
dependencies:
md-to-react-email: "npm:5.0.5"
md-to-react-email: "npm:^5.0.5"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/e124a1844704e3c48f189d2d2769ca9f1193499f1c7353a804161b1c5df373fa815255116e653f7228192bcd5ff826ff55eb15c972a8e7facb6c82721511c764
checksum: 10c0/68dc46d929ad0daf86f7d25e159455bdfbf4c0f0ca8ad152caf407f2eb154f4edb88912728cd5bba0c01f4806217e533e66baa02138c8ddcb5f2049719da7137
languageName: node
linkType: hard
"@react-email/preview@npm:0.0.12":
version: 0.0.12
resolution: "@react-email/preview@npm:0.0.12"
"@react-email/preview@npm:0.0.13":
version: 0.0.13
resolution: "@react-email/preview@npm:0.0.13"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/54c09fb599263b7e01f290cd3ed37db31b7370cb036851496067470f4873dfe82102e0746e390e9b83e27d9a054044259412521ba65eac7366fb5e17580b7787
checksum: 10c0/223cff5238f0bfc4f1c3cdd1ae4f8b92cde16a60594d4d5073607b7af0b3cfaf5ae10234ca531d9403c7143afcba2e71bc12c1aba75e51f0ae58fd9c0b8bf542
languageName: node
linkType: hard
"@react-email/render@npm:0.0.17":
version: 0.0.17
resolution: "@react-email/render@npm:0.0.17"
"@react-email/render@npm:1.2.3, @react-email/render@npm:^1.2.3":
version: 1.2.3
resolution: "@react-email/render@npm:1.2.3"
dependencies:
html-to-text: "npm:9.0.5"
js-beautify: "npm:^1.14.11"
react-promise-suspense: "npm:0.3.4"
peerDependencies:
react: ^18.2.0
react-dom: ^18.2.0
checksum: 10c0/34a1c5a55586d7cd723638d8cd5328fddf30015e5065a2e9d5204772e0532e84d63af1b2b1b8ec02cef52a8f57d34871200c0075a9ed7b2861362d8138d3db5e
languageName: node
linkType: hard
"@react-email/render@npm:1.0.5":
version: 1.0.5
resolution: "@react-email/render@npm:1.0.5"
dependencies:
html-to-text: "npm:9.0.5"
prettier: "npm:3.4.2"
react-promise-suspense: "npm:0.3.4"
html-to-text: "npm:^9.0.5"
prettier: "npm:^3.5.3"
react-promise-suspense: "npm:^0.3.4"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/ea3afc75d8b5e3035664d4f01cb19b5010bc69eac56d19e889f8ea86ed259537967a192d2f7becc92d5886bf1fe050bbd347e980d45915c05533754a699b50f7
checksum: 10c0/7ddd786e808fae6dc9aa456965ec5a67dff5d143b027c70538158e182a99a59b784a092c44f61ace20368ab5e31c31875ae1513593efe153d029c9870928d07b
languageName: node
linkType: hard
@ -15237,21 +15216,21 @@ __metadata:
languageName: node
linkType: hard
"@react-email/tailwind@npm:1.0.4":
version: 1.0.4
resolution: "@react-email/tailwind@npm:1.0.4"
"@react-email/tailwind@npm:1.2.2":
version: 1.2.2
resolution: "@react-email/tailwind@npm:1.2.2"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/47472ae00c1731ec9cce20e1083fcb405c049a378d28889fb5334d6d3fbd1526e13f6c775691f3fa5e6796a30b98b2f3e9e242a61454dfc097dc5f6f5dc5fc8d
checksum: 10c0/fa7d0a4d518a77cf0ec26c57aeac8a24509344225a19d6f3c1320a4357d88b2d48e592f312286ec59ef17333d875e3f3437cd09102d793896229e3f5502d079e
languageName: node
linkType: hard
"@react-email/text@npm:0.1.1":
version: 0.1.1
resolution: "@react-email/text@npm:0.1.1"
"@react-email/text@npm:0.1.5":
version: 0.1.5
resolution: "@react-email/text@npm:0.1.5"
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
checksum: 10c0/e88e2792bef38b94f0908caeb4355e4412f472e6642daf9eecd58054eef4e729f97d397395116f4957ac087af1951cb70c312ee28f952c47343eda2629213deb
checksum: 10c0/ad825332903917d1185ce7c5e4d19846496565996f82b71a873648dffc6342d382606a96a5a00a15a16abef5322540d56b481ad3ca21c8b34de9b598715ec149
languageName: node
linkType: hard
@ -19206,6 +19185,15 @@ __metadata:
languageName: node
linkType: hard
"@tiptap/extension-bold@npm:^3.4.2":
version: 3.4.2
resolution: "@tiptap/extension-bold@npm:3.4.2"
peerDependencies:
"@tiptap/core": ^3.4.2
checksum: 10c0/f37a5701432f47c09e979c9944edccfe5fe0009b51666bd0719096e8744465bf3c38c5a405510af57aa275208bde408f6c1cd0d685a265d1659cc89ddca47cb4
languageName: node
linkType: hard
"@tiptap/extension-bubble-menu@npm:^2.12.0":
version: 2.12.0
resolution: "@tiptap/extension-bubble-menu@npm:2.12.0"
@ -19290,6 +19278,15 @@ __metadata:
languageName: node
linkType: hard
"@tiptap/extension-heading@npm:^3.4.2":
version: 3.4.2
resolution: "@tiptap/extension-heading@npm:3.4.2"
peerDependencies:
"@tiptap/core": ^3.4.2
checksum: 10c0/90c61508b76469c9ae314539a363910f759e18a4090c34401d873a4e04d3b40aaf2d81f426b5403d9de035eee905fde86600ef8e6d3739318492abfa38d7e1d2
languageName: node
linkType: hard
"@tiptap/extension-history@npm:^2.11.5":
version: 2.12.0
resolution: "@tiptap/extension-history@npm:2.12.0"
@ -19310,6 +19307,15 @@ __metadata:
languageName: node
linkType: hard
"@tiptap/extension-image@npm:^3.4.4":
version: 3.4.4
resolution: "@tiptap/extension-image@npm:3.4.4"
peerDependencies:
"@tiptap/core": ^3.4.4
checksum: 10c0/d42667f3dab4dae8d1776b02fc4421b51d5cfd7dd80a4b871dc13000ed272b60dbc2e90f1209e715706810d92743d0c629687df1b2ec195bdaf424e1d308101f
languageName: node
linkType: hard
"@tiptap/extension-italic@npm:^2.11.5":
version: 2.12.0
resolution: "@tiptap/extension-italic@npm:2.12.0"
@ -19319,6 +19325,15 @@ __metadata:
languageName: node
linkType: hard
"@tiptap/extension-italic@npm:^3.4.2":
version: 3.4.2
resolution: "@tiptap/extension-italic@npm:3.4.2"
peerDependencies:
"@tiptap/core": ^3.4.2
checksum: 10c0/ea18f40b4676d97bc85a8d29a9625239189567260a4f13ebde3aca9253c7514d9ad21cde9ecea97b6de2dabb54db392a0504d483385b185d112e702dc5f288f7
languageName: node
linkType: hard
"@tiptap/extension-link@npm:^2.11.5":
version: 2.12.0
resolution: "@tiptap/extension-link@npm:2.12.0"
@ -19331,6 +19346,18 @@ __metadata:
languageName: node
linkType: hard
"@tiptap/extension-link@npm:^3.4.2":
version: 3.4.2
resolution: "@tiptap/extension-link@npm:3.4.2"
dependencies:
linkifyjs: "npm:^4.3.2"
peerDependencies:
"@tiptap/core": ^3.4.2
"@tiptap/pm": ^3.4.2
checksum: 10c0/6680403c34505f37ceb067c9277ccebc0f9b1126be570ca7f3e0730586963af0c483c9abb2ab8a88c3cfddb5d243fb49dbf920dc3ac0fcbc897a582919083808
languageName: node
linkType: hard
"@tiptap/extension-paragraph@npm:^2.11.5":
version: 2.12.0
resolution: "@tiptap/extension-paragraph@npm:2.12.0"
@ -19358,6 +19385,15 @@ __metadata:
languageName: node
linkType: hard
"@tiptap/extension-strike@npm:^3.4.2":
version: 3.4.2
resolution: "@tiptap/extension-strike@npm:3.4.2"
peerDependencies:
"@tiptap/core": ^3.4.2
checksum: 10c0/d763200cdcd579461492d6960705d56e9d5af4fcce026b826adc559ab5dbb2abb371cc65ac9e08b3398265a71a0bba96c864ed7a55ea69fdaf39e54d49ba8a6a
languageName: node
linkType: hard
"@tiptap/extension-table-cell@npm:^2.11.5":
version: 2.12.0
resolution: "@tiptap/extension-table-cell@npm:2.12.0"
@ -19403,6 +19439,15 @@ __metadata:
languageName: node
linkType: hard
"@tiptap/extension-underline@npm:^3.4.2":
version: 3.4.2
resolution: "@tiptap/extension-underline@npm:3.4.2"
peerDependencies:
"@tiptap/core": ^3.4.2
checksum: 10c0/f4b716e1dd10f3d56232fc6a1f658ca31733ea0c604ec8cf674cbe20b86c5ba84a99f32c60a85f99c87f10ee1e9967d4262a057ddfdfb513de8ff5ded7272e13
languageName: node
linkType: hard
"@tiptap/extensions@npm:^3.4.2":
version: 3.4.2
resolution: "@tiptap/extensions@npm:3.4.2"
@ -27599,16 +27644,6 @@ __metadata:
languageName: node
linkType: hard
"config-chain@npm:^1.1.13":
version: 1.1.13
resolution: "config-chain@npm:1.1.13"
dependencies:
ini: "npm:^1.3.4"
proto-list: "npm:~1.2.1"
checksum: 10c0/39d1df18739d7088736cc75695e98d7087aea43646351b028dfabd5508d79cf6ef4c5bcd90471f52cd87ae470d1c5490c0a8c1a292fbe6ee9ff688061ea0963e
languageName: node
linkType: hard
"configstore@npm:^5.0.1":
version: 5.0.1
resolution: "configstore@npm:5.0.1"
@ -29842,20 +29877,6 @@ __metadata:
languageName: node
linkType: hard
"editorconfig@npm:^1.0.4":
version: 1.0.4
resolution: "editorconfig@npm:1.0.4"
dependencies:
"@one-ini/wasm": "npm:0.1.1"
commander: "npm:^10.0.0"
minimatch: "npm:9.0.1"
semver: "npm:^7.5.3"
bin:
editorconfig: bin/editorconfig
checksum: 10c0/ed6985959d7b34a56e1c09bef118758c81c969489b768d152c93689fce8403b0452462e934f665febaba3478eebc0fd41c0a36100783eaadf6d926c4abc87a3d
languageName: node
linkType: hard
"ee-first@npm:1.1.1":
version: 1.1.1
resolution: "ee-first@npm:1.1.1"
@ -33485,7 +33506,7 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7, glob@npm:^10.4.2":
"glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7":
version: 10.4.5
resolution: "glob@npm:10.4.5"
dependencies:
@ -37913,23 +37934,6 @@ __metadata:
languageName: node
linkType: hard
"js-beautify@npm:^1.14.11":
version: 1.15.4
resolution: "js-beautify@npm:1.15.4"
dependencies:
config-chain: "npm:^1.1.13"
editorconfig: "npm:^1.0.4"
glob: "npm:^10.4.2"
js-cookie: "npm:^3.0.5"
nopt: "npm:^7.2.1"
bin:
css-beautify: js/bin/css-beautify.js
html-beautify: js/bin/html-beautify.js
js-beautify: js/bin/js-beautify.js
checksum: 10c0/300386f648579feacda98640742e8db50d4504bc896673af8bc784a5864585abf89ad8d1f257f2cfd4e3da951e0e4d1f027aa3c21537edb920bd498a0e27bd86
languageName: node
linkType: hard
"js-cookie@npm:^3.0.5":
version: 3.0.5
resolution: "js-cookie@npm:3.0.5"
@ -38910,6 +38914,13 @@ __metadata:
languageName: node
linkType: hard
"linkifyjs@npm:^4.3.2":
version: 4.3.2
resolution: "linkifyjs@npm:4.3.2"
checksum: 10c0/1a85e6b368304a4417567fe5e38651681e3e82465590836942d1b4f3c834cc35532898eb1e2479f6337d9144b297d418eb708b6be8ed0b3dc3954a3588e07971
languageName: node
linkType: hard
"listr2@npm:^4.0.5":
version: 4.0.5
resolution: "listr2@npm:4.0.5"
@ -39939,7 +39950,7 @@ __metadata:
languageName: node
linkType: hard
"md-to-react-email@npm:5.0.5":
"md-to-react-email@npm:^5.0.5":
version: 5.0.5
resolution: "md-to-react-email@npm:5.0.5"
dependencies:
@ -41694,15 +41705,6 @@ __metadata:
languageName: node
linkType: hard
"minimatch@npm:9.0.1":
version: 9.0.1
resolution: "minimatch@npm:9.0.1"
dependencies:
brace-expansion: "npm:^2.0.1"
checksum: 10c0/aa043eb8822210b39888a5d0d28df0017b365af5add9bd522f180d2a6962de1cbbf1bdeacdb1b17f410dc3336bc8d76fb1d3e814cdc65d00c2f68e01f0010096
languageName: node
linkType: hard
"minimatch@npm:9.0.3":
version: 9.0.3
resolution: "minimatch@npm:9.0.3"
@ -42860,7 +42862,7 @@ __metadata:
languageName: node
linkType: hard
"nopt@npm:^7.0.0, nopt@npm:^7.2.1":
"nopt@npm:^7.0.0":
version: 7.2.1
resolution: "nopt@npm:7.2.1"
dependencies:
@ -45254,7 +45256,7 @@ __metadata:
languageName: node
linkType: hard
"prettier@npm:3.4.2, prettier@npm:^3.1.1, prettier@npm:^3.2.5":
"prettier@npm:^3.1.1, prettier@npm:^3.2.5":
version: 3.4.2
resolution: "prettier@npm:3.4.2"
bin:
@ -45263,6 +45265,15 @@ __metadata:
languageName: node
linkType: hard
"prettier@npm:^3.5.3":
version: 3.6.2
resolution: "prettier@npm:3.6.2"
bin:
prettier: bin/prettier.cjs
checksum: 10c0/488cb2f2b99ec13da1e50074912870217c11edaddedeadc649b1244c749d15ba94e846423d062e2c4c9ae683e2d65f754de28889ba06e697ac4f988d44f45812
languageName: node
linkType: hard
"pretty-bytes@npm:^5.3.0":
version: 5.6.0
resolution: "pretty-bytes@npm:5.6.0"
@ -45342,10 +45353,10 @@ __metadata:
languageName: node
linkType: hard
"prismjs@npm:1.29.0":
version: 1.29.0
resolution: "prismjs@npm:1.29.0"
checksum: 10c0/d906c4c4d01b446db549b4f57f72d5d7e6ccaca04ecc670fb85cea4d4b1acc1283e945a9cbc3d81819084a699b382f970e02f9d1378e14af9808d366d9ed7ec6
"prismjs@npm:^1.30.0":
version: 1.30.0
resolution: "prismjs@npm:1.30.0"
checksum: 10c0/f56205bfd58ef71ccfcbcb691fd0eb84adc96c6ff21b0b69fc6fdcf02be42d6ef972ba4aed60466310de3d67733f6a746f89f2fb79c00bf217406d465b3e8f23
languageName: node
linkType: hard
@ -45736,13 +45747,6 @@ __metadata:
languageName: node
linkType: hard
"proto-list@npm:~1.2.1":
version: 1.2.4
resolution: "proto-list@npm:1.2.4"
checksum: 10c0/b9179f99394ec8a68b8afc817690185f3b03933f7b46ce2e22c1930dc84b60d09f5ad222beab4e59e58c6c039c7f7fcf620397235ef441a356f31f9744010e12
languageName: node
linkType: hard
"protobufjs@npm:^7.2.5, protobufjs@npm:^7.3.0":
version: 7.5.3
resolution: "protobufjs@npm:7.5.3"
@ -46519,7 +46523,7 @@ __metadata:
languageName: node
linkType: hard
"react-promise-suspense@npm:0.3.4":
"react-promise-suspense@npm:^0.3.4":
version: 0.3.4
resolution: "react-promise-suspense@npm:0.3.4"
dependencies:
@ -51632,6 +51636,7 @@ __metadata:
"@lingui/react": "npm:^5.1.2"
"@lingui/swc-plugin": "npm:^5.6.0"
"@lingui/vite-plugin": "npm:^5.1.2"
"@tiptap/core": "npm:^3.4.2"
"@types/react": "npm:^19"
"@types/react-dom": "npm:^19"
react-email: "npm:4.0.3"
@ -51673,10 +51678,17 @@ __metadata:
"@react-pdf/renderer": "npm:^4.1.6"
"@scalar/api-reference-react": "npm:^0.4.36"
"@tiptap/core": "npm:^3.4.2"
"@tiptap/extension-bold": "npm:^3.4.2"
"@tiptap/extension-document": "npm:^3.4.2"
"@tiptap/extension-hard-break": "npm:^3.4.2"
"@tiptap/extension-heading": "npm:^3.4.2"
"@tiptap/extension-image": "npm:^3.4.4"
"@tiptap/extension-italic": "npm:^3.4.2"
"@tiptap/extension-link": "npm:^3.4.2"
"@tiptap/extension-paragraph": "npm:^3.4.2"
"@tiptap/extension-strike": "npm:^3.4.2"
"@tiptap/extension-text": "npm:^3.4.2"
"@tiptap/extension-underline": "npm:^3.4.2"
"@tiptap/extensions": "npm:^3.4.2"
"@tiptap/react": "npm:^3.4.2"
"@types/apollo-upload-client": "npm:^17.0.2"
@ -51806,8 +51818,8 @@ __metadata:
"@ptc-org/nestjs-query-core": "npm:4.4.0"
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch"
"@ptc-org/nestjs-query-typeorm": "npm:4.2.1-alpha.2"
"@react-email/components": "npm:0.0.35"
"@react-email/render": "npm:0.0.17"
"@react-email/components": "npm:^0.5.3"
"@react-email/render": "npm:^1.2.3"
"@revertdotdev/revert-react": "npm:^0.0.21"
"@sentry/nestjs": "npm:^8.55.0"
"@sentry/node": "npm:9.26.0"
@ -52103,8 +52115,8 @@ __metadata:
"@nx/vite": "npm:21.3.11"
"@nx/web": "npm:21.3.11"
"@playwright/test": "npm:^1.46.0"
"@react-email/components": "npm:0.0.35"
"@react-email/render": "npm:0.0.17"
"@react-email/components": "npm:^0.5.3"
"@react-email/render": "npm:^1.2.3"
"@sentry/profiling-node": "npm:^9.26.0"
"@sentry/react": "npm:^9.26.0"
"@sentry/types": "npm:^8"