@@ -217,6 +228,7 @@ export const FormAdvancedTextFieldInput = ({
editor={editor}
readonly={readonly}
minHeight={editorMinHeight}
+ chrome={chrome}
/>
,
@@ -224,10 +236,6 @@ export const FormAdvancedTextFieldInput = ({
)
: null;
- if (!isDefined(editor)) {
- return null;
- }
-
return (
<>
(
[SidePanelPages.CommandMenuEdit, ],
[SidePanelPages.ComposeEmail, ],
[SidePanelPages.SendCampaignTest, ],
+ [SidePanelPages.EmailBlockSettings, ],
],
);
diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useOpenEmailBlockSettingsInSidePanel.ts b/packages/twenty-front/src/modules/side-panel/hooks/useOpenEmailBlockSettingsInSidePanel.ts
new file mode 100644
index 0000000000..486e4914a7
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/hooks/useOpenEmailBlockSettingsInSidePanel.ts
@@ -0,0 +1,22 @@
+import { useCallback } from 'react';
+import { t } from '@lingui/core/macro';
+import { SidePanelPages } from 'twenty-shared/types';
+import { IconAdjustments } from 'twenty-ui/icon';
+import { v4 } from 'uuid';
+
+import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
+
+export const useOpenEmailBlockSettingsInSidePanel = () => {
+ const { navigateSidePanelMenu } = useSidePanelMenu();
+
+ const openEmailBlockSettingsInSidePanel = useCallback(() => {
+ navigateSidePanelMenu({
+ page: SidePanelPages.EmailBlockSettings,
+ pageTitle: t`Block Settings`,
+ pageIcon: IconAdjustments,
+ pageId: v4(),
+ });
+ }, [navigateSidePanelMenu]);
+
+ return { openEmailBlockSettingsInSidePanel };
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailAlignmentInput.tsx b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailAlignmentInput.tsx
new file mode 100644
index 0000000000..1ff2730463
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailAlignmentInput.tsx
@@ -0,0 +1,72 @@
+import { styled } from '@linaria/react';
+import { IconAlignCenter, IconAlignLeft, IconAlignRight } from 'twenty-ui/icon';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
+
+import { StyledEmailFieldLabel } from '@/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel';
+
+const StyledAlignRow = styled.div`
+ display: flex;
+ gap: ${themeCssVariables.spacing[1]};
+`;
+
+const StyledAlignButton = styled.button<{ isActive: boolean }>`
+ align-items: center;
+ background: ${({ isActive }) =>
+ isActive ? themeCssVariables.background.transparent.medium : 'none'};
+ border: 1px solid
+ ${({ isActive }) =>
+ isActive ? themeCssVariables.border.color.strong : 'transparent'};
+ border-radius: ${themeCssVariables.border.radius.sm};
+ box-sizing: border-box;
+ color: ${({ isActive }) =>
+ isActive
+ ? themeCssVariables.font.color.primary
+ : themeCssVariables.font.color.tertiary};
+ cursor: pointer;
+ display: flex;
+ height: 28px;
+ justify-content: center;
+ padding: 0;
+ width: 32px;
+
+ &:hover {
+ background: ${themeCssVariables.background.transparent.light};
+ }
+`;
+
+export const CAMPAIGN_ALIGN_OPTIONS = [
+ { align: 'left', Icon: IconAlignLeft },
+ { align: 'center', Icon: IconAlignCenter },
+ { align: 'right', Icon: IconAlignRight },
+] as const;
+
+type EmailAlignmentInputProps = {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+};
+
+export const EmailAlignmentInput = ({
+ label,
+ value,
+ onChange,
+}: EmailAlignmentInputProps) => (
+
+ {label}
+
+ {CAMPAIGN_ALIGN_OPTIONS.map(({ align, Icon }) => (
+ onChange(align)}
+ >
+
+
+ ))}
+
+
+);
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailBlockSettingsFieldInput.tsx b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailBlockSettingsFieldInput.tsx
new file mode 100644
index 0000000000..7e72b2ec7c
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailBlockSettingsFieldInput.tsx
@@ -0,0 +1,87 @@
+import { useLingui } from '@lingui/react/macro';
+import { type MessageDescriptor } from '@lingui/core';
+
+import { EmailAlignmentInput } from '@/side-panel/pages/email-block-settings/components/EmailAlignmentInput';
+import { EmailColorInput } from '@/side-panel/pages/email-block-settings/components/EmailColorInput';
+import { EmailSizeInput } from '@/side-panel/pages/email-block-settings/components/EmailSizeInput';
+import { StyledEmailFieldLabel } from '@/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel';
+import { TextArea } from '@/ui/input/components/TextArea';
+import { TextInput } from '@/ui/input/components/TextInput';
+
+export type EmailStyleFieldKind =
+ | 'text'
+ | 'color'
+ | 'box'
+ | 'size'
+ | 'alignment'
+ | 'textarea';
+
+type EmailBlockSettingsFieldInputProps = {
+ field: {
+ label: MessageDescriptor;
+ input: EmailStyleFieldKind;
+ placeholder?: string;
+ };
+ value: string;
+ onChange: (value: string) => void;
+};
+
+export const EmailBlockSettingsFieldInput = ({
+ field,
+ value,
+ onChange,
+}: EmailBlockSettingsFieldInputProps) => {
+ const { i18n } = useLingui();
+ const label = i18n._(field.label);
+
+ switch (field.input) {
+ case 'color':
+ return (
+
+ );
+ case 'size':
+ return (
+
+ );
+ case 'alignment':
+ return (
+
+ );
+ case 'textarea':
+ return (
+
+ {label}
+
+
+ );
+ default:
+ return (
+
+ {label}
+
+
+ );
+ }
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailBoxSidesInput.tsx b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailBoxSidesInput.tsx
new file mode 100644
index 0000000000..255bcb4841
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailBoxSidesInput.tsx
@@ -0,0 +1,151 @@
+import { styled } from '@linaria/react';
+import { useLingui } from '@lingui/react/macro';
+import { useState } from 'react';
+import { IconFrame, IconSquare } from 'twenty-ui/icon';
+import { LightIconButton } from 'twenty-ui/input';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
+
+import { StyledEmailFieldLabel } from '@/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel';
+import { TextInput } from '@/ui/input/components/TextInput';
+
+const StyledRow = styled.div`
+ align-items: center;
+ display: flex;
+ gap: ${themeCssVariables.spacing[1]};
+
+ & > :first-child {
+ flex: 1;
+ }
+`;
+
+const StyledSidesGrid = styled.div`
+ display: grid;
+ gap: ${themeCssVariables.spacing[1]};
+ grid-template-columns: repeat(4, 1fr);
+ margin-top: ${themeCssVariables.spacing[1]};
+`;
+
+const StyledUnitChip = styled.div`
+ align-items: center;
+ border: 1px solid ${themeCssVariables.border.color.medium};
+ border-radius: ${themeCssVariables.border.radius.sm};
+ box-sizing: border-box;
+ color: ${themeCssVariables.font.color.tertiary};
+ display: flex;
+ flex-shrink: 0;
+ font-size: ${themeCssVariables.font.size.sm};
+ height: 32px;
+ padding: 0 ${themeCssVariables.spacing[2]};
+`;
+
+const SIDE_KEYS = ['top', 'right', 'bottom', 'left'] as const;
+
+const SIDE_PLACEHOLDERS: Record<(typeof SIDE_KEYS)[number], string> = {
+ top: 'T',
+ right: 'R',
+ bottom: 'B',
+ left: 'L',
+};
+
+const toDisplayAmount = (token: string): string =>
+ token.endsWith('px') && !Number.isNaN(Number(token.slice(0, -2)))
+ ? token.slice(0, -2)
+ : token;
+
+const toCssToken = (input: string): string => {
+ const trimmed = input.trim();
+
+ if (trimmed === '') {
+ return '0px';
+ }
+
+ return Number.isNaN(Number(trimmed)) ? trimmed : `${trimmed}px`;
+};
+
+const areAllSidesEqual = ({ top, right, bottom, left }: CssBoxSides) =>
+ top === right && right === bottom && bottom === left;
+
+export type CssBoxSides = {
+ top: string;
+ right: string;
+ bottom: string;
+ left: string;
+};
+
+type EmailBoxSidesInputProps = {
+ label: string;
+ sides: CssBoxSides;
+ onChange: (sides: CssBoxSides) => void;
+ placeholder?: string;
+};
+
+export const EmailBoxSidesInput = ({
+ label,
+ sides,
+ onChange,
+ placeholder,
+}: EmailBoxSidesInputProps) => {
+ const { t } = useLingui();
+ const [isPerSide, setIsPerSide] = useState(!areAllSidesEqual(sides));
+
+ const commitAllSides = (input: string) => {
+ const token = input.trim() === '' ? '' : toCssToken(input);
+
+ onChange({ top: token, right: token, bottom: token, left: token });
+ };
+
+ const commitSide = (side: (typeof SIDE_KEYS)[number], input: string) => {
+ onChange({
+ ...sides,
+ [side]: input.trim() === '' ? '' : toCssToken(input),
+ });
+ };
+
+ return (
+
+ {label}
+
+ {isPerSide ? (
+
+ {SIDE_KEYS.map((side) => (
+ commitSide(side, input)}
+ placeholder={SIDE_PLACEHOLDERS[side]}
+ fullWidth
+ />
+ ))}
+
+ ) : (
+
+ )}
+ px
+ {
+ setIsPerSide(false);
+ commitAllSides(toDisplayAmount(sides.top));
+ }}
+ />
+ setIsPerSide(true)}
+ />
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailColorInput.tsx b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailColorInput.tsx
new file mode 100644
index 0000000000..3ea86f4c73
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailColorInput.tsx
@@ -0,0 +1,74 @@
+import { styled } from '@linaria/react';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
+
+import { StyledEmailFieldLabel } from '@/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel';
+import { TextInput } from '@/ui/input/components/TextInput';
+
+const StyledRow = styled.div`
+ align-items: center;
+ display: flex;
+ gap: ${themeCssVariables.spacing[2]};
+
+ & > :last-child {
+ flex: 1;
+ }
+`;
+
+const StyledColorSwatchInput = styled.input`
+ background: none;
+ border: 1px solid ${themeCssVariables.border.color.medium};
+ border-radius: ${themeCssVariables.border.radius.sm};
+ box-sizing: border-box;
+ cursor: pointer;
+ flex-shrink: 0;
+ height: 32px;
+ padding: 2px;
+ width: 32px;
+
+ &::-webkit-color-swatch-wrapper {
+ padding: 0;
+ }
+
+ &::-webkit-color-swatch {
+ border: none;
+ border-radius: ${themeCssVariables.border.radius.xs};
+ }
+`;
+
+const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
+
+// oxlint-disable-next-line twenty/no-hardcoded-colors
+const COLOR_SWATCH_FALLBACK = '#ffffff';
+
+type EmailColorInputProps = {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+};
+
+export const EmailColorInput = ({
+ label,
+ value,
+ onChange,
+ placeholder,
+}: EmailColorInputProps) => {
+ return (
+
+ {label}
+
+ onChange(event.target.value)}
+ />
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailPageStyleSection.tsx b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailPageStyleSection.tsx
new file mode 100644
index 0000000000..ea8d449b9e
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailPageStyleSection.tsx
@@ -0,0 +1,152 @@
+import { styled } from '@linaria/react';
+import { useLingui } from '@lingui/react/macro';
+import { type Editor } from '@tiptap/core';
+import { useEditorState } from '@tiptap/react';
+import {
+ type CanvasTheme,
+ isDefined,
+ resolveCanvasTheme,
+} from 'twenty-shared/utils';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
+
+import { EmailBlockSettingsFieldInput } from '@/side-panel/pages/email-block-settings/components/EmailBlockSettingsFieldInput';
+import {
+ EmailBoxSidesInput,
+ type CssBoxSides,
+} from '@/side-panel/pages/email-block-settings/components/EmailBoxSidesInput';
+import { EMAIL_BODY_THEME_FIELDS } from '@/side-panel/pages/email-block-settings/constants/EmailBodyThemeFields';
+import { EMAIL_PAGE_THEME_FIELDS } from '@/side-panel/pages/email-block-settings/constants/EmailPageThemeFields';
+import { type EmailThemeField } from '@/side-panel/pages/email-block-settings/types/EmailThemeField';
+
+const StyledContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${themeCssVariables.spacing[3]};
+ padding: ${themeCssVariables.spacing[4]};
+`;
+
+const StyledGroupTitle = styled.div`
+ color: ${themeCssVariables.font.color.primary};
+ font-size: ${themeCssVariables.font.size.md};
+ font-weight: ${themeCssVariables.font.weight.medium};
+
+ &:not(:first-child) {
+ margin-top: ${themeCssVariables.spacing[3]};
+ }
+`;
+
+const StyledHint = styled.div`
+ color: ${themeCssVariables.font.color.tertiary};
+ font-size: ${themeCssVariables.font.size.sm};
+ padding: ${themeCssVariables.spacing[4]};
+`;
+
+const themeBoxValueToSides = (value: string): CssBoxSides => {
+ const tokens = value.trim().split(/\s+/);
+
+ if (tokens.length === 4) {
+ return {
+ top: tokens[0],
+ right: tokens[1],
+ bottom: tokens[2],
+ left: tokens[3],
+ };
+ }
+
+ if (tokens.length === 3) {
+ return {
+ top: tokens[0],
+ right: tokens[1],
+ bottom: tokens[2],
+ left: tokens[1],
+ };
+ }
+
+ if (tokens.length === 2) {
+ return {
+ top: tokens[0],
+ right: tokens[1],
+ bottom: tokens[0],
+ left: tokens[1],
+ };
+ }
+
+ return { top: value, right: value, bottom: value, left: value };
+};
+
+const sidesToThemeBoxValue = ({ top, right, bottom, left }: CssBoxSides) =>
+ top === right && right === bottom && bottom === left
+ ? top
+ : `${top} ${right} ${bottom} ${left}`;
+
+type EmailPageStyleSectionProps = {
+ editor: Editor;
+};
+
+export const EmailPageStyleSection = ({
+ editor,
+}: EmailPageStyleSectionProps) => {
+ const { t, i18n } = useLingui();
+
+ const canvasTheme = useEditorState({
+ editor,
+ selector: ({ editor: currentEditor }) =>
+ resolveCanvasTheme(currentEditor.state.doc.attrs.canvasTheme),
+ });
+
+ if (!isDefined(canvasTheme)) {
+ return (
+
+ {t`Select a section, columns, button or divider in the email body to edit its settings.`}
+
+ );
+ }
+
+ const setThemeValue = (themeKey: keyof CanvasTheme, value: string) => {
+ editor
+ .chain()
+ .command(({ tr }) => {
+ tr.setDocAttribute('canvasTheme', {
+ ...canvasTheme,
+ [themeKey]: value,
+ });
+ return true;
+ })
+ .run();
+ };
+
+ const renderThemeField = (field: EmailThemeField) => {
+ if (field.input === 'box') {
+ return (
+
+ setThemeValue(field.property, sidesToThemeBoxValue(sides))
+ }
+ placeholder={field.placeholder}
+ />
+ );
+ }
+
+ return (
+ setThemeValue(field.property, value)}
+ />
+ );
+ };
+
+ return (
+
+ {t`Page style`}
+ {EMAIL_PAGE_THEME_FIELDS.map(renderThemeField)}
+
+ {t`Body`}
+ {EMAIL_BODY_THEME_FIELDS.map(renderThemeField)}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailSizeInput.tsx b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailSizeInput.tsx
new file mode 100644
index 0000000000..9389337388
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/EmailSizeInput.tsx
@@ -0,0 +1,87 @@
+import { styled } from '@linaria/react';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
+
+import { parseCssSizeValue } from '@/advanced-text-editor/utils/parseCssSizeValue';
+import { StyledEmailFieldLabel } from '@/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel';
+import { TextInput } from '@/ui/input/components/TextInput';
+
+const StyledRow = styled.div`
+ align-items: center;
+ display: flex;
+ gap: ${themeCssVariables.spacing[2]};
+
+ & > :first-child {
+ flex: 1;
+ }
+`;
+
+const StyledUnitSelect = styled.select`
+ background: ${themeCssVariables.background.transparent.lighter};
+ border: 1px solid ${themeCssVariables.border.color.medium};
+ border-radius: ${themeCssVariables.border.radius.sm};
+ color: ${themeCssVariables.font.color.secondary};
+ cursor: pointer;
+ font-family: inherit;
+ font-size: ${themeCssVariables.font.size.sm};
+ height: 32px;
+ padding: 0 ${themeCssVariables.spacing[1]};
+`;
+
+const SIZE_UNITS = ['px', '%', 'em'] as const;
+
+type EmailSizeInputProps = {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+};
+
+export const EmailSizeInput = ({
+ label,
+ value,
+ onChange,
+ placeholder,
+}: EmailSizeInputProps) => {
+ const { amount, unit } = parseCssSizeValue(value);
+ const displayedAmount = amount === '' ? value.trim() : amount;
+
+ const commit = (nextAmount: string, nextUnit: string) => {
+ const trimmedAmount = nextAmount.trim();
+
+ if (trimmedAmount === '') {
+ onChange('');
+ return;
+ }
+
+ if (!/^-?(\d+|\d*\.\d+)$/.test(trimmedAmount)) {
+ onChange(trimmedAmount);
+ return;
+ }
+
+ onChange(`${trimmedAmount}${nextUnit}`);
+ };
+
+ return (
+
+ {label}
+
+ commit(nextAmount, unit)}
+ placeholder={placeholder ?? '0'}
+ fullWidth
+ />
+ commit(displayedAmount, event.target.value)}
+ >
+ {SIZE_UNITS.map((sizeUnit) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/SidePanelEmailBlockSettingsPage.tsx b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/SidePanelEmailBlockSettingsPage.tsx
new file mode 100644
index 0000000000..021c7424a7
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/SidePanelEmailBlockSettingsPage.tsx
@@ -0,0 +1,245 @@
+import { styled } from '@linaria/react';
+import { useLingui } from '@lingui/react/macro';
+import { type Editor } from '@tiptap/core';
+import { useEditorState } from '@tiptap/react';
+import {
+ isDefined,
+ resolveCanvasTheme,
+ TIPTAP_NODE_TYPES,
+} from 'twenty-shared/utils';
+import { IconTrash } from 'twenty-ui/icon';
+import { LightIconButton } from 'twenty-ui/input';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
+
+import { activeEmailEditorState } from '@/advanced-text-editor/states/activeEmailEditorState';
+import { getBlockSelectionTarget } from '@/advanced-text-editor/utils/getBlockSelectionTarget';
+import { getBlockStyle } from '@/advanced-text-editor/utils/getBlockStyle';
+import { EmailBlockSettingsFieldInput } from '@/side-panel/pages/email-block-settings/components/EmailBlockSettingsFieldInput';
+import {
+ EmailBoxSidesInput,
+ type CssBoxSides,
+} from '@/side-panel/pages/email-block-settings/components/EmailBoxSidesInput';
+import { EmailPageStyleSection } from '@/side-panel/pages/email-block-settings/components/EmailPageStyleSection';
+import { EMAIL_BLOCK_SETTINGS_FIELDS } from '@/side-panel/pages/email-block-settings/constants/EmailBlockSettingsFields';
+import { getEmailBlockLabel } from '@/side-panel/pages/email-block-settings/utils/getEmailBlockLabel';
+import { getEffectiveSectionStyleValue } from '@/side-panel/pages/email-block-settings/utils/getEffectiveSectionStyleValue';
+import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+
+const StyledContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${themeCssVariables.spacing[4]};
+ padding: ${themeCssVariables.spacing[4]};
+`;
+
+const StyledHint = styled.div`
+ color: ${themeCssVariables.font.color.tertiary};
+ font-size: ${themeCssVariables.font.size.sm};
+ padding: ${themeCssVariables.spacing[4]};
+`;
+
+const StyledBlockHeader = styled.div`
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+`;
+
+const StyledBlockTitle = styled.div`
+ color: ${themeCssVariables.font.color.primary};
+ font-size: ${themeCssVariables.font.size.md};
+ font-weight: ${themeCssVariables.font.weight.medium};
+`;
+
+const BORDER_STYLE_COMPANIONS: Record = {
+ borderWidth: 'borderStyle',
+ borderTopWidth: 'borderTopStyle',
+};
+
+const BOX_FIELD_SIDE_PROPERTIES: Record<
+ string,
+ [string, string, string, string]
+> = {
+ padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
+ margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'],
+ borderRadius: [
+ 'borderTopLeftRadius',
+ 'borderTopRightRadius',
+ 'borderBottomRightRadius',
+ 'borderBottomLeftRadius',
+ ],
+};
+
+const EmailBlockSettingsContent = ({ editor }: { editor: Editor }) => {
+ const { i18n, t } = useLingui();
+ const target = useEditorState({
+ editor,
+ selector: ({ editor: currentEditor }) =>
+ getBlockSelectionTarget(currentEditor),
+ });
+
+ if (!isDefined(target)) {
+ return ;
+ }
+
+ const fields = EMAIL_BLOCK_SETTINGS_FIELDS[target.nodeType] ?? [];
+ const styles = getBlockStyle(target.attrs.style);
+ const canvasTheme = resolveCanvasTheme(editor.state.doc.attrs.canvasTheme);
+
+ const displayedStyleValue = (property: string) =>
+ styles[property] ??
+ (target.nodeType === TIPTAP_NODE_TYPES.SECTION
+ ? getEffectiveSectionStyleValue(property, canvasTheme)
+ : '');
+
+ const updateTargetAttributes = (attrs: Record) => {
+ editor
+ .chain()
+ .command(({ tr }) => {
+ const node = tr.doc.nodeAt(target.pos);
+ if (!isDefined(node)) {
+ return false;
+ }
+
+ tr.setNodeMarkup(target.pos, undefined, { ...node.attrs, ...attrs });
+ return true;
+ })
+ .run();
+ };
+
+ const handleRemoveBlock = () => {
+ editor
+ .chain()
+ .focus()
+ .command(({ tr }) => {
+ const node = tr.doc.nodeAt(target.pos);
+ if (!isDefined(node)) {
+ return false;
+ }
+
+ tr.delete(target.pos, target.pos + node.nodeSize);
+ return true;
+ })
+ .run();
+ };
+
+ const handleFieldChange = (field: (typeof fields)[number], value: string) => {
+ if (field.kind === 'attribute') {
+ if (field.property === 'width') {
+ const trimmed = value.trim();
+ if (trimmed === '' || trimmed === 'auto') {
+ updateTargetAttributes({ width: null });
+ } else if (Number.isFinite(Number(trimmed)) && Number(trimmed) >= 0) {
+ updateTargetAttributes({ width: Number(trimmed) });
+ }
+ return;
+ }
+
+ updateTargetAttributes({ [field.property]: value });
+ return;
+ }
+
+ const nextStyles = { ...styles };
+ if (value.trim() === '') {
+ delete nextStyles[field.property];
+ } else {
+ nextStyles[field.property] = value;
+ }
+
+ const borderStyleProperty = BORDER_STYLE_COMPANIONS[field.property];
+ if (isDefined(borderStyleProperty)) {
+ if (value.trim() === '' || value.trim() === '0px') {
+ delete nextStyles[borderStyleProperty];
+ } else {
+ nextStyles[borderStyleProperty] ??= 'solid';
+ }
+ }
+
+ updateTargetAttributes({ style: nextStyles });
+ };
+
+ const handleBoxFieldChange = (
+ sideProperties: [string, string, string, string],
+ sides: CssBoxSides,
+ ) => {
+ const nextStyles = { ...styles };
+ const sideValues = [sides.top, sides.right, sides.bottom, sides.left];
+
+ sideProperties.forEach((property, index) => {
+ if (sideValues[index].trim() === '') {
+ delete nextStyles[property];
+ } else {
+ nextStyles[property] = sideValues[index];
+ }
+ });
+
+ updateTargetAttributes({ style: nextStyles });
+ };
+
+ return (
+
+
+
+ {getEmailBlockLabel(target.nodeType)}
+
+
+
+ {fields.map((field) => {
+ const key = `${target.nodeType}-${target.pos}-${field.property}`;
+ const sideProperties = BOX_FIELD_SIDE_PROPERTIES[field.property];
+
+ if (
+ field.kind === 'style' &&
+ field.input === 'box' &&
+ isDefined(sideProperties)
+ ) {
+ return (
+ handleBoxFieldChange(sideProperties, sides)}
+ placeholder={field.placeholder}
+ />
+ );
+ }
+
+ return (
+ handleFieldChange(field, value)}
+ />
+ );
+ })}
+
+ );
+};
+
+export const SidePanelEmailBlockSettingsPage = () => {
+ const { t } = useLingui();
+ const activeEmailEditor = useAtomStateValue(activeEmailEditorState);
+
+ if (!isDefined(activeEmailEditor) || activeEmailEditor.isDestroyed) {
+ return (
+ {t`Open an email editor to edit block settings.`}
+ );
+ }
+
+ return ;
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel.tsx b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel.tsx
new file mode 100644
index 0000000000..fc0f149038
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/components/StyledEmailFieldLabel.tsx
@@ -0,0 +1,9 @@
+import { styled } from '@linaria/react';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
+
+export const StyledEmailFieldLabel = styled.div`
+ color: ${themeCssVariables.font.color.light};
+ font-size: ${themeCssVariables.font.size.xs};
+ font-weight: ${themeCssVariables.font.weight.semiBold};
+ margin-bottom: ${themeCssVariables.spacing[1]};
+`;
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/constants/EmailBlockSettingsFields.ts b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/constants/EmailBlockSettingsFields.ts
new file mode 100644
index 0000000000..47acc5c992
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/constants/EmailBlockSettingsFields.ts
@@ -0,0 +1,340 @@
+/* oxlint-disable twenty/no-hardcoded-colors --
+ placeholders show literal inline CSS examples for email content, where
+ theme variables do not exist */
+import { type MessageDescriptor } from '@lingui/core';
+import { msg } from '@lingui/core/macro';
+import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
+
+import { type EmailStyleFieldKind } from '@/side-panel/pages/email-block-settings/components/EmailBlockSettingsFieldInput';
+
+export type EmailBlockSettingsField = {
+ label: MessageDescriptor;
+ kind: 'style' | 'attribute';
+ property: string;
+ input: EmailStyleFieldKind;
+ placeholder?: string;
+};
+
+export const EMAIL_BLOCK_SETTINGS_FIELDS: Record<
+ string,
+ EmailBlockSettingsField[]
+> = {
+ [TIPTAP_NODE_TYPES.SECTION]: [
+ {
+ label: msg`Text color`,
+ kind: 'style',
+ property: 'color',
+ input: 'color',
+ },
+ {
+ label: msg`Font size`,
+ kind: 'style',
+ property: 'fontSize',
+ input: 'size',
+ placeholder: '14',
+ },
+ {
+ label: msg`Line height`,
+ kind: 'style',
+ property: 'lineHeight',
+ input: 'text',
+ placeholder: '1.5',
+ },
+ {
+ label: msg`Letter spacing`,
+ kind: 'style',
+ property: 'letterSpacing',
+ input: 'size',
+ placeholder: '0',
+ },
+ {
+ label: msg`Alignment`,
+ kind: 'style',
+ property: 'textAlign',
+ input: 'alignment',
+ },
+ {
+ label: msg`Background`,
+ kind: 'style',
+ property: 'backgroundColor',
+ input: 'color',
+ },
+ {
+ label: msg`Padding`,
+ kind: 'style',
+ property: 'padding',
+ input: 'box',
+ placeholder: '12',
+ },
+ {
+ label: msg`Corner radius`,
+ kind: 'style',
+ property: 'borderRadius',
+ input: 'box',
+ placeholder: '8',
+ },
+ {
+ label: msg`Border`,
+ kind: 'style',
+ property: 'borderWidth',
+ input: 'size',
+ placeholder: '0',
+ },
+ {
+ label: msg`Border color`,
+ kind: 'style',
+ property: 'borderColor',
+ input: 'color',
+ },
+ ],
+ [TIPTAP_NODE_TYPES.COLUMNS]: [
+ {
+ label: msg`Text color`,
+ kind: 'style',
+ property: 'color',
+ input: 'color',
+ },
+ {
+ label: msg`Font size`,
+ kind: 'style',
+ property: 'fontSize',
+ input: 'size',
+ placeholder: '14',
+ },
+ {
+ label: msg`Line height`,
+ kind: 'style',
+ property: 'lineHeight',
+ input: 'text',
+ placeholder: '1.5',
+ },
+ {
+ label: msg`Letter spacing`,
+ kind: 'style',
+ property: 'letterSpacing',
+ input: 'size',
+ placeholder: '0',
+ },
+ {
+ label: msg`Alignment`,
+ kind: 'style',
+ property: 'textAlign',
+ input: 'alignment',
+ },
+ {
+ label: msg`Background`,
+ kind: 'style',
+ property: 'backgroundColor',
+ input: 'color',
+ },
+ {
+ label: msg`Padding`,
+ kind: 'style',
+ property: 'padding',
+ input: 'box',
+ placeholder: '12',
+ },
+ {
+ label: msg`Corner radius`,
+ kind: 'style',
+ property: 'borderRadius',
+ input: 'box',
+ placeholder: '8',
+ },
+ {
+ label: msg`Border`,
+ kind: 'style',
+ property: 'borderWidth',
+ input: 'size',
+ placeholder: '0',
+ },
+ {
+ label: msg`Border color`,
+ kind: 'style',
+ property: 'borderColor',
+ input: 'color',
+ },
+ ],
+ [TIPTAP_NODE_TYPES.COLUMN]: [
+ {
+ label: msg`Text color`,
+ kind: 'style',
+ property: 'color',
+ input: 'color',
+ },
+ {
+ label: msg`Font size`,
+ kind: 'style',
+ property: 'fontSize',
+ input: 'size',
+ placeholder: '14',
+ },
+ {
+ label: msg`Line height`,
+ kind: 'style',
+ property: 'lineHeight',
+ input: 'text',
+ placeholder: '1.5',
+ },
+ {
+ label: msg`Letter spacing`,
+ kind: 'style',
+ property: 'letterSpacing',
+ input: 'size',
+ placeholder: '0',
+ },
+ {
+ label: msg`Alignment`,
+ kind: 'style',
+ property: 'textAlign',
+ input: 'alignment',
+ },
+ {
+ label: msg`Width`,
+ kind: 'style',
+ property: 'width',
+ input: 'size',
+ placeholder: '50%',
+ },
+ {
+ label: msg`Background`,
+ kind: 'style',
+ property: 'backgroundColor',
+ input: 'color',
+ },
+ {
+ label: msg`Padding`,
+ kind: 'style',
+ property: 'padding',
+ input: 'box',
+ placeholder: '12',
+ },
+ {
+ label: msg`Corner radius`,
+ kind: 'style',
+ property: 'borderRadius',
+ input: 'box',
+ placeholder: '8',
+ },
+ {
+ label: msg`Border`,
+ kind: 'style',
+ property: 'borderWidth',
+ input: 'size',
+ placeholder: '0',
+ },
+ {
+ label: msg`Border color`,
+ kind: 'style',
+ property: 'borderColor',
+ input: 'color',
+ },
+ ],
+ [TIPTAP_NODE_TYPES.BUTTON]: [
+ {
+ label: msg`Alignment`,
+ kind: 'attribute',
+ property: 'align',
+ input: 'alignment',
+ },
+ {
+ label: msg`Link URL`,
+ kind: 'attribute',
+ property: 'href',
+ input: 'text',
+ placeholder: 'https://',
+ },
+ {
+ label: msg`Background`,
+ kind: 'style',
+ property: 'backgroundColor',
+ input: 'color',
+ },
+ {
+ label: msg`Text color`,
+ kind: 'style',
+ property: 'color',
+ input: 'color',
+ },
+ {
+ label: msg`Padding`,
+ kind: 'style',
+ property: 'padding',
+ input: 'box',
+ placeholder: '10',
+ },
+ {
+ label: msg`Corner radius`,
+ kind: 'style',
+ property: 'borderRadius',
+ input: 'box',
+ placeholder: '6',
+ },
+ ],
+ [TIPTAP_NODE_TYPES.IMAGE]: [
+ {
+ label: msg`Alignment`,
+ kind: 'attribute',
+ property: 'align',
+ input: 'alignment',
+ },
+ {
+ label: msg`Link URL`,
+ kind: 'attribute',
+ property: 'href',
+ input: 'text',
+ placeholder: 'https://',
+ },
+ {
+ label: msg`Source`,
+ kind: 'attribute',
+ property: 'src',
+ input: 'text',
+ placeholder: 'https://',
+ },
+ {
+ label: msg`Alt text`,
+ kind: 'attribute',
+ property: 'alt',
+ input: 'text',
+ },
+ {
+ label: msg`Width`,
+ kind: 'attribute',
+ property: 'width',
+ input: 'text',
+ placeholder: 'auto',
+ },
+ ],
+ [TIPTAP_NODE_TYPES.HTML]: [
+ {
+ label: msg`HTML`,
+ kind: 'attribute',
+ property: 'html',
+ input: 'textarea',
+ placeholder: 'Hello
',
+ },
+ ],
+ [TIPTAP_NODE_TYPES.DIVIDER]: [
+ {
+ label: msg`Thickness`,
+ kind: 'style',
+ property: 'borderTopWidth',
+ input: 'size',
+ placeholder: '1',
+ },
+ {
+ label: msg`Color`,
+ kind: 'style',
+ property: 'borderTopColor',
+ input: 'color',
+ },
+ {
+ label: msg`Margin`,
+ kind: 'style',
+ property: 'margin',
+ input: 'box',
+ placeholder: '16',
+ },
+ ],
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/constants/EmailBodyThemeFields.ts b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/constants/EmailBodyThemeFields.ts
new file mode 100644
index 0000000000..b48be7beb8
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/constants/EmailBodyThemeFields.ts
@@ -0,0 +1,24 @@
+import { msg } from '@lingui/core/macro';
+
+import { type EmailThemeField } from '@/side-panel/pages/email-block-settings/types/EmailThemeField';
+
+export const EMAIL_BODY_THEME_FIELDS: EmailThemeField[] = [
+ { label: msg`Alignment`, property: 'textAlign', input: 'alignment' },
+ { label: msg`Text`, property: 'textColor', input: 'color' },
+ { label: msg`Background`, property: 'bodyBackground', input: 'color' },
+ { label: msg`Width`, property: 'width', input: 'size', placeholder: '600' },
+ { label: msg`Padding`, property: 'padding', input: 'box', placeholder: '24' },
+ {
+ label: msg`Corner radius`,
+ property: 'cornerRadius',
+ input: 'box',
+ placeholder: '8',
+ },
+ {
+ label: msg`Border`,
+ property: 'borderWidth',
+ input: 'size',
+ placeholder: '0',
+ },
+ { label: msg`Border color`, property: 'borderColor', input: 'color' },
+];
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/constants/EmailPageThemeFields.ts b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/constants/EmailPageThemeFields.ts
new file mode 100644
index 0000000000..d8cb5f4f3d
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/constants/EmailPageThemeFields.ts
@@ -0,0 +1,13 @@
+import { msg } from '@lingui/core/macro';
+
+import { type EmailThemeField } from '@/side-panel/pages/email-block-settings/types/EmailThemeField';
+
+export const EMAIL_PAGE_THEME_FIELDS: EmailThemeField[] = [
+ { label: msg`Background`, property: 'pageBackground', input: 'color' },
+ {
+ label: msg`Padding`,
+ property: 'pagePadding',
+ input: 'box',
+ placeholder: '24',
+ },
+];
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/types/EmailThemeField.ts b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/types/EmailThemeField.ts
new file mode 100644
index 0000000000..af94655b7c
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/types/EmailThemeField.ts
@@ -0,0 +1,11 @@
+import { type MessageDescriptor } from '@lingui/core';
+import { type CanvasTheme } from 'twenty-shared/utils';
+
+import { type EmailStyleFieldKind } from '@/side-panel/pages/email-block-settings/components/EmailBlockSettingsFieldInput';
+
+export type EmailThemeField = {
+ label: MessageDescriptor;
+ property: keyof CanvasTheme;
+ input: EmailStyleFieldKind;
+ placeholder?: string;
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/utils/getEffectiveSectionStyleValue.ts b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/utils/getEffectiveSectionStyleValue.ts
new file mode 100644
index 0000000000..e9504cecb0
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/utils/getEffectiveSectionStyleValue.ts
@@ -0,0 +1,23 @@
+import { type CanvasTheme } from 'twenty-shared/utils';
+
+export const getEffectiveSectionStyleValue = (
+ property: string,
+ canvasTheme: CanvasTheme | null,
+): string => {
+ switch (property) {
+ case 'color':
+ return canvasTheme?.textColor ?? '';
+ case 'textAlign':
+ return canvasTheme?.textAlign ?? 'left';
+ case 'backgroundColor':
+ return canvasTheme?.bodyBackground ?? '';
+ case 'fontSize':
+ return '14px';
+ case 'lineHeight':
+ return '1.5';
+ case 'borderColor':
+ return canvasTheme?.borderColor ?? '';
+ default:
+ return '0px';
+ }
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/utils/getEmailBlockLabel.ts b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/utils/getEmailBlockLabel.ts
new file mode 100644
index 0000000000..661d04c836
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/email-block-settings/utils/getEmailBlockLabel.ts
@@ -0,0 +1,23 @@
+import { t } from '@lingui/core/macro';
+import { TIPTAP_NODE_TYPES } from 'twenty-shared/utils';
+
+export const getEmailBlockLabel = (nodeType: string): string => {
+ switch (nodeType) {
+ case TIPTAP_NODE_TYPES.SECTION:
+ return t`Section`;
+ case TIPTAP_NODE_TYPES.COLUMNS:
+ return t`Columns`;
+ case TIPTAP_NODE_TYPES.COLUMN:
+ return t`Column`;
+ case TIPTAP_NODE_TYPES.BUTTON:
+ return t`Button`;
+ case TIPTAP_NODE_TYPES.DIVIDER:
+ return t`Divider`;
+ case TIPTAP_NODE_TYPES.HTML:
+ return t`HTML`;
+ case TIPTAP_NODE_TYPES.IMAGE:
+ return t`Image`;
+ default:
+ return nodeType;
+ }
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts
index 9fa85ae41e..0c5df59a4e 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteStep.ts
@@ -2,7 +2,6 @@ import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { flowComponentState } from '@/workflow/states/flowComponentState';
-import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { useDeleteWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep';
import { useResetWorkflowAiAgentPermissionsStateOnSidePanelClose } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useResetWorkflowAiAgentPermissionsStateOnSidePanelClose';
import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema';
@@ -17,9 +16,6 @@ export const useDeleteStep = () => {
const { getUpdatableWorkflowVersion } =
useGetUpdatableWorkflowVersionOrThrow();
const { closeSidePanelMenu } = useSidePanelMenu();
- const workflowVisualizerWorkflowId = useAtomComponentStateValue(
- workflowVisualizerWorkflowIdComponentState,
- );
const flow = useAtomComponentStateValue(flowComponentState);
const deleteStep = async (stepId: string) => {
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-upgrade-version-command.module.ts
index 57b94bc2d8..2e58aa7677 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-upgrade-version-command.module.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-upgrade-version-command.module.ts
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
+import { AddEmailBlockSettingsCommandMenuItemCommand } from 'src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785921674941-add-email-block-settings-command-menu-item.command';
import { RepairOrphanCoreWorkflowVersionsCommand } from 'src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785600000000-repair-orphan-core-workflow-versions.command';
-import { SyncDiscardDraftWorkflowAvailabilityExpressionCommand } from 'src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1786000000000-sync-discard-draft-workflow-availability-expression.command';
+import { SyncDiscardDraftWorkflowAvailabilityExpressionCommand } from 'src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785858486000-sync-discard-draft-workflow-availability-expression.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
@@ -17,6 +18,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceIteratorModule,
],
providers: [
+ AddEmailBlockSettingsCommandMenuItemCommand,
RepairOrphanCoreWorkflowVersionsCommand,
SyncDiscardDraftWorkflowAvailabilityExpressionCommand,
],
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1786000000000-sync-discard-draft-workflow-availability-expression.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785858486000-sync-discard-draft-workflow-availability-expression.command.ts
similarity index 98%
rename from packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1786000000000-sync-discard-draft-workflow-availability-expression.command.ts
rename to packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785858486000-sync-discard-draft-workflow-availability-expression.command.ts
index 2d9ffa47e7..a5894d2ecc 100644
--- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1786000000000-sync-discard-draft-workflow-availability-expression.command.ts
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785858486000-sync-discard-draft-workflow-availability-expression.command.ts
@@ -9,7 +9,7 @@ import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/deco
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
-@RegisteredWorkspaceCommand('2.28.0', 1786000000000)
+@RegisteredWorkspaceCommand('2.28.0', 1785858486000)
@Command({
name: 'upgrade:2-28:sync-discard-draft-workflow-availability-expression',
description:
diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785921674941-add-email-block-settings-command-menu-item.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785921674941-add-email-block-settings-command-menu-item.command.ts
new file mode 100644
index 0000000000..7281354e46
--- /dev/null
+++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-28/2-28-workspace-command-1785921674941-add-email-block-settings-command-menu-item.command.ts
@@ -0,0 +1,139 @@
+import { Command } from 'nest-commander';
+
+import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
+import { isDefined } from 'twenty-shared/utils';
+
+import { ProvisionedWorkspaceCommandRunner } from 'src/database/commands/command-runners/provisioned-workspace.command-runner';
+import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service';
+import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner';
+import { ApplicationService } from 'src/engine/core-modules/application/application.service';
+import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
+import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
+import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant';
+import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant';
+import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
+
+const EMAIL_BLOCK_SETTINGS_UNIVERSAL_IDENTIFIER =
+ STANDARD_COMMAND_MENU_ITEMS.emailBlockSettings.universalIdentifier;
+
+@RegisteredWorkspaceCommand('2.28.0', 1785921674941)
+@Command({
+ name: 'upgrade:2-28:add-email-block-settings-command-menu-item',
+ description:
+ 'Add the pinned Block Settings command menu item on message campaign record pages to existing workspaces',
+})
+export class AddEmailBlockSettingsCommandMenuItemCommand extends ProvisionedWorkspaceCommandRunner {
+ constructor(
+ protected readonly workspaceIteratorService: WorkspaceIteratorService,
+ private readonly applicationService: ApplicationService,
+ private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
+ private readonly workspaceCacheService: WorkspaceCacheService,
+ ) {
+ super(workspaceIteratorService);
+ }
+
+ override async runOnWorkspace({
+ workspaceId,
+ options,
+ }: RunOnWorkspaceArgs): Promise {
+ const isDryRun = options.dryRun ?? false;
+
+ const { twentyStandardFlatApplication } =
+ await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
+ { workspaceId },
+ );
+
+ const {
+ flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
+ flatObjectMetadataMaps,
+ } = await this.workspaceCacheService.getOrRecompute(workspaceId, [
+ 'flatCommandMenuItemMaps',
+ 'flatObjectMetadataMaps',
+ ]);
+
+ if (
+ !isDefined(
+ flatObjectMetadataMaps.byUniversalIdentifier[
+ STANDARD_OBJECTS.messageCampaign.universalIdentifier
+ ],
+ )
+ ) {
+ this.logger.log(
+ `Message campaign object does not exist for workspace ${workspaceId}, skipping`,
+ );
+
+ return;
+ }
+
+ if (
+ isDefined(
+ existingFlatCommandMenuItemMaps.byUniversalIdentifier[
+ EMAIL_BLOCK_SETTINGS_UNIVERSAL_IDENTIFIER
+ ],
+ )
+ ) {
+ this.logger.log(
+ `Block Settings command menu item already exists for workspace ${workspaceId}, skipping`,
+ );
+
+ return;
+ }
+
+ const { allFlatEntityMaps: standardAllFlatEntityMaps } =
+ computeTwentyStandardApplicationAllFlatEntityMaps({
+ now: new Date().toISOString(),
+ workspaceId,
+ twentyStandardApplicationId: twentyStandardFlatApplication.id,
+ });
+
+ const itemToCreate =
+ standardAllFlatEntityMaps.flatCommandMenuItemMaps.byUniversalIdentifier[
+ EMAIL_BLOCK_SETTINGS_UNIVERSAL_IDENTIFIER
+ ];
+
+ if (!isDefined(itemToCreate)) {
+ throw new Error(
+ `Block Settings command menu item is missing from the standard application definition`,
+ );
+ }
+
+ this.logger.log(
+ `${isDryRun ? '[DRY RUN] ' : ''}Adding the Block Settings command menu item for workspace ${workspaceId}`,
+ );
+
+ if (isDryRun) {
+ return;
+ }
+
+ const validateAndBuildResult =
+ await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunLegacyWorkspaceMigration(
+ {
+ isSystemBuild: true,
+ allFlatEntityOperationByMetadataName: {
+ commandMenuItem: {
+ flatEntityToCreate: [itemToCreate],
+ flatEntityToDelete: [],
+ flatEntityToUpdate: [],
+ },
+ },
+ workspaceId,
+ applicationUniversalIdentifier:
+ twentyStandardFlatApplication.universalIdentifier,
+ },
+ );
+
+ if (validateAndBuildResult.status === 'fail') {
+ throw new Error(
+ `Failed to add the Block Settings command menu item for workspace ${workspaceId}: ${JSON.stringify(
+ validateAndBuildResult,
+ null,
+ 2,
+ )}`,
+ );
+ }
+
+ this.logger.log(
+ `Added the Block Settings command menu item for workspace ${workspaceId}`,
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/services/unsubscribe-content.service.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/unsubscribe-content.service.ts
index 22c593bbb9..0aad56b5ba 100644
--- a/packages/twenty-server/src/engine/core-modules/emailing-domain/services/unsubscribe-content.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/services/unsubscribe-content.service.ts
@@ -5,6 +5,7 @@ import { isNonEmptyString } from '@sniptt/guards';
import { type EmailingDomainSendEmailInput } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-send-email-input.type';
import { UnsubscribeTokenService } from 'src/engine/core-modules/emailing-domain/services/unsubscribe-token.service';
import { buildUnsubscribeHeaders } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-headers.util';
+import { appendHtmlFooter } from 'src/engine/core-modules/emailing-domain/utils/append-html-footer.util';
import { buildUnsubscribeHtmlFooter } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-html-footer.util';
import { buildUnsubscribeTextFooter } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-text-footer.util';
import { buildUnsubscribeUrls } from 'src/engine/core-modules/emailing-domain/utils/build-unsubscribe-urls.util';
@@ -41,7 +42,10 @@ export class UnsubscribeContentService {
...email,
text: `${email.text}${buildUnsubscribeTextFooter(unsubscribeUrls.webUrl)}`,
html: isNonEmptyString(email.html)
- ? `${email.html}${buildUnsubscribeHtmlFooter(unsubscribeUrls.webUrl)}`
+ ? appendHtmlFooter(
+ email.html,
+ buildUnsubscribeHtmlFooter(unsubscribeUrls.webUrl),
+ )
: email.html,
headers: [
...(email.headers ?? []),
diff --git a/packages/twenty-server/src/engine/core-modules/emailing-domain/utils/__tests__/append-html-footer.util.spec.ts b/packages/twenty-server/src/engine/core-modules/emailing-domain/utils/__tests__/append-html-footer.util.spec.ts
new file mode 100644
index 0000000000..0191053374
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/emailing-domain/utils/__tests__/append-html-footer.util.spec.ts
@@ -0,0 +1,43 @@
+import { appendHtmlFooter } from 'src/engine/core-modules/emailing-domain/utils/append-html-footer.util';
+
+const FOOTER = 'Unsubscribe
';
+
+describe('appendHtmlFooter', () => {
+ it('should insert the footer inside the body of a full document', () => {
+ const html = appendHtmlFooter(
+ 'Hello
',
+ FOOTER,
+ );
+
+ expect(html).toBe(
+ 'Hello
Unsubscribe
',
+ );
+ });
+
+ it('should insert before the closing html tag when there is no body', () => {
+ const html = appendHtmlFooter('Hello
', FOOTER);
+
+ expect(html).toBe('Hello
Unsubscribe
');
+ });
+
+ it('should append to a bare fragment', () => {
+ expect(appendHtmlFooter('Hello
', FOOTER)).toBe(
+ 'Hello
Unsubscribe
',
+ );
+ });
+
+ it('should match the closing tag case-insensitively', () => {
+ expect(appendHtmlFooter('Hi', FOOTER)).toBe(
+ 'HiUnsubscribe
',
+ );
+ });
+
+ it('should use the last closing body tag when one appears in content', () => {
+ const html = appendHtmlFooter(
+ 'talk about </body>
',
+ FOOTER,
+ );
+
+ expect(html).toContain('Unsubscribe