diff --git a/package.json b/package.json
index fc8a1bad4f1..56d2cbdbfde 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/twenty-emails/package.json b/packages/twenty-emails/package.json
index dc09c2eb711..07f3da620eb 100644
--- a/packages/twenty-emails/package.json
+++ b/packages/twenty-emails/package.json
@@ -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"
diff --git a/packages/twenty-emails/src/index.ts b/packages/twenty-emails/src/index.ts
index 07ff30d9233..afc26b303d8 100644
--- a/packages/twenty-emails/src/index.ts
+++ b/packages/twenty-emails/src/index.ts
@@ -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';
diff --git a/packages/twenty-emails/src/utils/email-renderer/email-renderer.tsx b/packages/twenty-emails/src/utils/email-renderer/email-renderer.tsx
new file mode 100644
index 00000000000..99d59bf2fef
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/email-renderer.tsx
@@ -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 (
+
+
+
+
+
+
+ {jsxNodes}
+
+
+
+ );
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/marks/bold.tsx b/packages/twenty-emails/src/utils/email-renderer/marks/bold.tsx
new file mode 100644
index 00000000000..139cd71d6b1
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/marks/bold.tsx
@@ -0,0 +1,6 @@
+import { type ReactNode } from 'react';
+import { type TipTapMark } from 'twenty-shared/utils';
+
+export const bold = (_: TipTapMark, children: ReactNode): ReactNode => {
+ return {children};
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/marks/italic.tsx b/packages/twenty-emails/src/utils/email-renderer/marks/italic.tsx
new file mode 100644
index 00000000000..0fb98fbda23
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/marks/italic.tsx
@@ -0,0 +1,6 @@
+import { type ReactNode } from 'react';
+import { type TipTapMark } from 'twenty-shared/utils';
+
+export const italic = (_: TipTapMark, children: ReactNode): ReactNode => {
+ return {children};
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/marks/link.tsx b/packages/twenty-emails/src/utils/email-renderer/marks/link.tsx
new file mode 100644
index 00000000000..1fd49299ec9
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/marks/link.tsx
@@ -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 (
+
+ {children}
+
+ );
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/marks/strike.tsx b/packages/twenty-emails/src/utils/email-renderer/marks/strike.tsx
new file mode 100644
index 00000000000..198d0ccd583
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/marks/strike.tsx
@@ -0,0 +1,6 @@
+import { type ReactNode } from 'react';
+import { type TipTapMark } from 'twenty-shared/utils';
+
+export const strike = (_: TipTapMark, children: ReactNode): ReactNode => {
+ return {children};
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/marks/underline.tsx b/packages/twenty-emails/src/utils/email-renderer/marks/underline.tsx
new file mode 100644
index 00000000000..36aa0e48639
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/marks/underline.tsx
@@ -0,0 +1,6 @@
+import { type ReactNode } from 'react';
+import { type TipTapMark } from 'twenty-shared/utils';
+
+export const underline = (_: TipTapMark, children: ReactNode): ReactNode => {
+ return {children};
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/nodes/heading.tsx b/packages/twenty-emails/src/utils/email-renderer/nodes/heading.tsx
new file mode 100644
index 00000000000..0cbafd5c2f6
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/nodes/heading.tsx
@@ -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 = {
+ 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 (
+
+ {content}
+
+ );
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/nodes/image.tsx b/packages/twenty-emails/src/utils/email-renderer/nodes/image.tsx
new file mode 100644
index 00000000000..505a416e345
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/nodes/image.tsx
@@ -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 (
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/nodes/paragraph.tsx b/packages/twenty-emails/src/utils/email-renderer/nodes/paragraph.tsx
new file mode 100644
index 00000000000..1325d179234
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/nodes/paragraph.tsx
@@ -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 {mappedNodeContent(node)};
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/nodes/text.tsx b/packages/twenty-emails/src/utils/email-renderer/nodes/text.tsx
new file mode 100644
index 00000000000..e274e4a05e3
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/nodes/text.tsx
@@ -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 <> >;
+ }
+
+ return <>{text}>;
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/nodes/variable-tag.tsx b/packages/twenty-emails/src/utils/email-renderer/nodes/variable-tag.tsx
new file mode 100644
index 00000000000..38d51eff274
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/nodes/variable-tag.tsx
@@ -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 <> >;
+ }
+
+ return <>{variable}>;
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/renderers/render-mark.tsx b/packages/twenty-emails/src/utils/email-renderer/renderers/render-mark.tsx
new file mode 100644
index 00000000000..c5ea0cdef4e
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/renderers/render-mark.tsx
@@ -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 || <> >;
+ 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);
+};
diff --git a/packages/twenty-emails/src/utils/email-renderer/renderers/render-node.tsx b/packages/twenty-emails/src/utils/email-renderer/renderers/render-node.tsx
new file mode 100644
index 00000000000..0b71d24a6be
--- /dev/null
+++ b/packages/twenty-emails/src/utils/email-renderer/renderers/render-node.tsx
@@ -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 {component};
+ })
+ .filter((n) => n !== null);
+};
diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json
index 90cd99bc26a..40bc0e346fc 100644
--- a/packages/twenty-front/package.json
+++ b/packages/twenty-front/package.json
@@ -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",
diff --git a/packages/twenty-front/src/modules/ui/layout/fullscreen/hooks/useFullScreenModal.tsx b/packages/twenty-front/src/modules/ui/layout/fullscreen/hooks/useFullScreenModal.tsx
new file mode 100644
index 00000000000..f4cbf45e9a7
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/layout/fullscreen/hooks/useFullScreenModal.tsx
@@ -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(null);
+
+ const renderFullScreenModal = (
+ children: React.ReactNode,
+ isOpen: boolean,
+ ) => {
+ if (!isOpen) return null;
+
+ return createPortal(
+
+ }
+ hasClosePageButton={hasClosePageButton}
+ onClosePage={onClose}
+ />
+ {children}
+ ,
+ document.body,
+ );
+ };
+
+ return {
+ overlayRef,
+ renderFullScreenModal,
+ };
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction.tsx
index 5b2077ca6bc..80573e74ba3 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction.tsx
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction.tsx
@@ -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(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(
-
- }
- hasClosePageButton={!isMobile}
- onClosePage={handleExitFullScreen}
- />
-
-
-
-
-
-
- ,
- 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(
+
+
+
+
+
+
,
+ isFullScreen,
+ );
return (
!loading && (
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/__stories__/WorkflowEditActionServerlessFunction.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/__stories__/WorkflowEditActionServerlessFunction.stories.tsx
new file mode 100644
index 00000000000..b580253f284
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/__stories__/WorkflowEditActionServerlessFunction.stories.tsx
@@ -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 = {
+ 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;
+
+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(),
+ },
+ },
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx
index e30d1bd363f..e96c111332c 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx
@@ -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}
/>
-
{!actionOptions.readonly && }
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor.tsx
new file mode 100644
index 00000000000..b80adeceb08
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor.tsx
@@ -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 (
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody.tsx
new file mode 100644
index 00000000000..ca6cafe6fb4
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody.tsx
@@ -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(
+
+
+
+
+
,
+ isFullScreen,
+ );
+
+ if (!isDefined(editor)) {
+ return null;
+ }
+
+ return (
+ <>
+
+ {label ? {label} : null}
+
+
+
+ {!isFullScreen && (
+
+ )}
+
+
+ {!readonly && !isFullScreen && (
+
+
+
+ )}
+
+
+ {VariablePicker && !readonly ? (
+
+ ) : null}
+
+
+ {hint && {hint}}
+ {error && {error}}
+
+
+ {fullScreenOverlay}
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowEmailEditor.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowEmailEditor.stories.tsx
new file mode 100644
index 00000000000..5f2999211bc
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowEmailEditor.stories.tsx
@@ -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 Loading editor...
;
+ }
+
+ return ;
+};
+
+const meta: Meta = {
+ 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;
+
+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...',
+ },
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowSendEmailBody.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowSendEmailBody.stories.tsx
new file mode 100644
index 00000000000..82b558123cb
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowSendEmailBody.stories.tsx
@@ -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 = {
+ 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;
+
+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,
+ },
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/image-bubble-menu/ImageBubbleMenu.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/image-bubble-menu/ImageBubbleMenu.tsx
new file mode 100644
index 00000000000..c7eea22f3bf
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/image-bubble-menu/ImageBubbleMenu.tsx
@@ -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 (
+
+
+ {alignmentActions.map(({ align, Icon, onClick, isActive }) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/link-bubble-menu/LinkBubbleMenu.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/link-bubble-menu/LinkBubbleMenu.tsx
new file mode 100644
index 00000000000..024a0ace7c5
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/link-bubble-menu/LinkBubbleMenu.tsx
@@ -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 (
+
+
+
+ {menuActions.map(({ Icon, onClick }) => {
+ return (
+
+ );
+ })}
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton.tsx
new file mode 100644
index 00000000000..4e0c06aab57
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton.tsx
@@ -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) => 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 (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover.tsx
new file mode 100644
index 00000000000..c5eb44bf9b8
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover.tsx
@@ -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 | FocusEvent,
+ ) => {
+ 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 (
+ {
+ setValue(defaultValue);
+ }}
+ dropdownComponents={
+
+
+
+
+
+ }
+ dropdownId={dropdownId}
+ isDropdownInModal={true}
+ clickableComponent={
+
+ }
+ />
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu.tsx
new file mode 100644
index 00000000000..8705bdb1653
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu.tsx
@@ -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 (
+
+
+
+ {menuActions.map(({ Icon, onClick, isActive }) => {
+ return (
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TurnIntoBlockDropdown.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TurnIntoBlockDropdown.tsx
new file mode 100644
index 00000000000..c1bfa1653d6
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TurnIntoBlockDropdown.tsx
@@ -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 (
+
+
+ {options.map(({ id, title, icon, onClick }) => (
+
+
+ }
+ dropdownId={dropdownId}
+ clickableComponent={
+
+
+ {activeTitle}
+
+ }
+ dropdownOffset={{
+ y: parseInt(theme.spacing(1), 10),
+ }}
+ />
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImage.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImage.ts
new file mode 100644
index 00000000000..2ada3295d50
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImage.ts
@@ -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({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ align: {
+ default: 'left',
+ },
+ };
+ },
+
+ addNodeView: () => {
+ return ReactNodeViewRenderer(ResizableImageView);
+ },
+});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImageView.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImageView.tsx
new file mode 100644
index 00000000000..fd57cdd153a
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImageView.tsx
@@ -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(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(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) => {
+ 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 (
+
+
+
+
+ {/* Show resize handles when hovering over image OR actively resizing */}
+ {(isHovering || isDefined(resizeParams)) && (
+ <>
+ handleImageHandleMouseDown('left', e)}
+ />
+ handleImageHandleMouseDown('right', e)}
+ />
+ >
+ )}
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImageExtension.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImageExtension.ts
new file mode 100644
index 00000000000..9b4f9de589c
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImageExtension.ts
@@ -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 & {};
+
+type UploadImageStorage = {
+ placeholderImages: Set;
+};
+
+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,
+ }),
+ ];
+ },
+});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImagePlugin.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImagePlugin.ts
new file mode 100644
index 00000000000..b70de5ae5d0
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImagePlugin.ts
@@ -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;
+ 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;
+ },
+ },
+ });
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor.ts
new file mode 100644
index 00000000000..9bc1172641f
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor.ts
@@ -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;
+ 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;
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useTextBubbleState.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useTextBubbleState.ts
new file mode 100644
index 00000000000..5c456a30fed
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useTextBubbleState.ts
@@ -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;
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useTurnIntoBlockOptions.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useTurnIntoBlockOptions.ts
new file mode 100644
index 00000000000..532be0a63cc
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useTurnIntoBlockOptions.ts
@@ -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 });
+ },
+ },
+ ],
+ });
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/utils/isTextSelected.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/utils/isTextSelected.ts
new file mode 100644
index 00000000000..eae922ff21f
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/utils/isTextSelected.ts
@@ -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;
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdown.tsx b/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdown.tsx
index 65dcb420423..fdcf37dd48b 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdown.tsx
+++ b/packages/twenty-front/src/modules/workflow/workflow-variables/components/WorkflowVariablesDropdown.tsx
@@ -109,6 +109,7 @@ export const WorkflowVariablesDropdown = ({
return (
{
+ // 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);
+ }
+};
diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json
index b8d4cb244d0..11848be1005 100644
--- a/packages/twenty-server/package.json
+++ b/packages/twenty-server/package.json
@@ -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",
diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts
index a83dd9b8207..d90c5cf99bd 100644
--- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts
@@ -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,
});
diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts
index 203c12e3ecb..fda3d1fdf05 100644
--- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts
@@ -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 email content';
+ }),
+}));
+
describe('ApprovedAccessDomainService', () => {
let service: ApprovedAccessDomainService;
let approvedAccessDomainRepository: Repository;
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
index afe568404f1..4fece60d0c5 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
@@ -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);
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts
index 7a56a160bb9..fdf963572ad 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts
@@ -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 email content';
+ }),
+}));
+
describe('ResetPasswordService', () => {
let service: ResetPasswordService;
let userRepository: Repository;
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts
index 24914f6bf33..583ff93d7a5 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts
@@ -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);
diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts
index 8f518716aef..f3c3ef2e7df 100644
--- a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts
@@ -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,
});
diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts
index ca763aa3b20..f09ef2e7f92 100644
--- a/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.ts
@@ -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,
);
diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts
index b6722f5aa33..eba44e6de98 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts
@@ -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 email content';
+ }),
+}));
+
describe('WorkspaceInvitationService', () => {
let service: WorkspaceInvitationService;
let appTokenRepository: Repository;
diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts
index 905dbda7726..70169868889 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts
@@ -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,
});
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts
index 5cf1e3be129..f787156e98a 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts
@@ -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,
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts
index ab88e6496b3..715ed1cbd9d 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts
@@ -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;
diff --git a/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts b/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts
index 170601ce025..262c42587f6 100644
--- a/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts
+++ b/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts
@@ -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 | null;
+
+ @WorkspaceJoinColumn('workflow')
+ workflowId: string | null;
+
@WorkspaceDynamicRelation({
type: RelationType.MANY_TO_ONE,
argsFactory: (oppositeObjectMetadata) => ({
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/__tests__/messaging-send-message-gmail.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/__tests__/messaging-send-message-gmail.spec.ts
new file mode 100644
index 00000000000..2aef95c90ff
--- /dev/null
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/__tests__/messaging-send-message-gmail.spec.ts
@@ -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;
+ let oAuth2ClientProvider: jest.Mocked;
+
+ 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,
+ );
+ 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: 'This is HTML content
',
+ };
+
+ 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(
+ 'This is HTML content
',
+ );
+ 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: 'HTML content
',
+ };
+
+ 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('""');
+ });
+});
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts
index 04b70eeaa26..58c5026a6f1 100644
--- a/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/services/messaging-send-message.service.ts
@@ -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;
}
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/gmail-mime-message.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/gmail-mime-message.spec.ts
new file mode 100644
index 00000000000..527e8d3bfc4
--- /dev/null
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/utils/__tests__/gmail-mime-message.spec.ts
@@ -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: 'HTML content
',
+ };
+
+ 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('HTML content
');
+ 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('""');
+ });
+});
diff --git a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow.workspace-entity.ts b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow.workspace-entity.ts
index 71c77c6c8e3..a430353814c 100644
--- a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow.workspace-entity.ts
+++ b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow.workspace-entity.ts
@@ -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;
+ @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;
+
@WorkspaceField({
standardId: WORKFLOW_STANDARD_FIELD_IDS.createdBy,
type: FieldMetadataType.ACTOR,
diff --git a/packages/twenty-server/src/utils/parse-email-body.ts b/packages/twenty-server/src/utils/parse-email-body.ts
new file mode 100644
index 00000000000..111d5619419
--- /dev/null
+++ b/packages/twenty-server/src/utils/parse-email-body.ts
@@ -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;
+ }
+};
diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts
index f8ab61dbfd6..7166914c350 100644
--- a/packages/twenty-shared/src/utils/index.ts
+++ b/packages/twenty-shared/src/utils/index.ts
@@ -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';
diff --git a/packages/twenty-shared/src/utils/tiptap/index.ts b/packages/twenty-shared/src/utils/tiptap/index.ts
new file mode 100644
index 00000000000..aeb2ede4cf2
--- /dev/null
+++ b/packages/twenty-shared/src/utils/tiptap/index.ts
@@ -0,0 +1 @@
+export * from './tiptap-marks';
diff --git a/packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts b/packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts
new file mode 100644
index 00000000000..83cc301846e
--- /dev/null
+++ b/packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts
@@ -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;
+}
diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
index 41ef1401e36..b0cee9d18f6 100644
--- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
+++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
@@ -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,
diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts
index 16273670b85..707cdb2cd5c 100644
--- a/packages/twenty-ui/src/display/index.ts
+++ b/packages/twenty-ui/src/display/index.ts
@@ -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,
diff --git a/packages/twenty-ui/src/input/code-editor/components/CodeEditor.tsx b/packages/twenty-ui/src/input/code-editor/components/CodeEditor.tsx
index d6f559c2a13..01a1dcfb58b 100644
--- a/packages/twenty-ui/src/input/code-editor/components/CodeEditor.tsx
+++ b/packages/twenty-ui/src/input/code-editor/components/CodeEditor.tsx
@@ -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};
diff --git a/yarn.lock b/yarn.lock
index 2210a50bbd6..d2e62c9e3f8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"