Fix Email composer rich text to HTML conversion (#19872)

This commit is contained in:
neo773 2026-04-21 12:22:05 +05:30 committed by GitHub
parent a8d4039629
commit a1de37e424
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 24 additions and 96 deletions

View file

@ -111,7 +111,7 @@ export const EmailComposerFields = ({
placeholder={t`Type something or press "/" to see commands`}
minHeight={120}
maxWidth={600}
contentType="json"
contentType="html"
/>
<EmailAttachmentsField
label={t`Attachments`}

View file

@ -23,7 +23,7 @@ import { marked } from 'marked';
import { type DependencyList, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
export type AdvancedTextEditorContentType = 'json' | 'markdown';
export type AdvancedTextEditorContentType = 'json' | 'html' | 'markdown';
type UseAdvancedTextEditorProps = {
placeholder: string | undefined;

View file

@ -120,7 +120,7 @@ export const FormAdvancedTextFieldInput = ({
defaultValue,
contentType,
onUpdate: (editor) => {
if (contentType === 'markdown') {
if (contentType === 'markdown' || contentType === 'html') {
onChange(editor.getHTML());
} else {
const jsonContent = editor.getJSON();

View file

@ -1,9 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { render, toPlainText } from '@react-email/render';
import { toPlainText } from '@react-email/render';
import DOMPurify from 'dompurify';
import { reactMarkupFromJSON } from 'twenty-emails';
import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
import {
ConnectedAccountProvider,
@ -31,7 +30,6 @@ import { MessagingAccountAuthenticationService } from 'src/modules/messaging/mes
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
import { parseEmailBody } from 'src/utils/parse-email-body';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@Injectable()
export class EmailComposerService {
@ -391,15 +389,12 @@ export class EmailComposerService {
options.attachmentsFileFolder,
);
const parsedBody = parseEmailBody(body);
const reactMarkup = reactMarkupFromJSON(parsedBody);
const htmlBody = await render(reactMarkup);
const plainTextBody = toPlainText(htmlBody);
const { JSDOM } = await import('jsdom');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const sanitizedHtmlBody = purify.sanitize(htmlBody || '');
const sanitizedHtmlBody = purify.sanitize(body || '');
const plainTextBody = toPlainText(sanitizedHtmlBody);
const sanitizedSubject = purify.sanitize(subject || '');
let threadExternalId: string | undefined;

View file

@ -24,7 +24,7 @@ export const EmailToolInputZodSchema = z.object({
'Recipients object with to, cc, and bcc fields (comma-separated)',
),
subject: z.string().describe('The email subject line'),
body: z.string().describe('The email body content (HTML or plain text)'),
body: z.string().describe('The email body content in HTML format'),
connectedAccountId: z
.string()
.refine((val) => isValidUuid(val))

View file

@ -0,0 +1,10 @@
import { render } from '@react-email/render';
import { type JSONContent, reactMarkupFromJSON } from 'twenty-emails';
export const renderRichTextToHtml = async (
jsonContent: JSONContent,
): Promise<string> => {
const reactMarkup = reactMarkupFromJSON(jsonContent);
return render(reactMarkup);
};

View file

@ -5,6 +5,7 @@ import { resolveInput, resolveRichTextVariables } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
import { renderRichTextToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
@ -67,9 +68,13 @@ export class ToolExecutorWorkflowAction implements WorkflowAction {
const emailInput = toolInput as WorkflowSendEmailActionInput;
if (emailInput.body) {
const resolvedBody = resolveRichTextVariables(emailInput.body, context);
const bodyJson = JSON.parse(resolvedBody!);
const htmlBody = await renderRichTextToHtml(bodyJson);
toolInput = {
...emailInput,
body: resolveRichTextVariables(emailInput.body, context),
body: htmlBody,
};
}
}

View file

@ -1,71 +0,0 @@
import { parseEmailBody } from 'src/utils/parse-email-body';
describe('parseEmailBody', () => {
it('should parse valid JSON content', () => {
const jsonContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello World' }],
},
],
};
const result = parseEmailBody(JSON.stringify(jsonContent));
expect(result).toEqual(jsonContent);
});
it('should return plain string when JSON parsing fails', () => {
const plainText = 'This is plain text, not JSON';
const result = parseEmailBody(plainText);
expect(result).toBe(plainText);
});
it('should parse JSON content with hardBreak nodes', () => {
const jsonWithHardBreaks = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Line 1' },
{ type: 'hardBreak' },
{ type: 'text', text: 'Line 2' },
],
},
],
};
const result = parseEmailBody(JSON.stringify(jsonWithHardBreaks));
expect(result).toEqual(jsonWithHardBreaks);
expect(
(result as typeof jsonWithHardBreaks).content[0].content,
).toContainEqual({
type: 'hardBreak',
});
});
it('should handle empty string', () => {
const result = parseEmailBody('');
expect(result).toBe('');
});
it('should handle JSON array format', () => {
const arrayContent = [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Content' }],
},
];
const result = parseEmailBody(JSON.stringify(arrayContent));
expect(result).toEqual(arrayContent);
});
});

View file

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