diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts index 8293a26262a..3237eb62e97 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts @@ -23,6 +23,7 @@ import { import { type ComposeEmailParams } from 'src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type'; import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-tool/types/email-composer-result.type'; import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util'; +import { renderEmailBodyToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-email-body.util'; import { type ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool-execution-context.type'; import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; @@ -394,7 +395,8 @@ export class EmailComposerService { const window = new JSDOM('').window; const purify = DOMPurify(window); - const sanitizedHtmlBody = purify.sanitize(body || ''); + const htmlBody = await renderEmailBodyToHtml(body ?? ''); + const sanitizedHtmlBody = purify.sanitize(htmlBody); const plainTextBody = toPlainText(sanitizedHtmlBody); const sanitizedSubject = purify.sanitize(subject || ''); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-tool.schema.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-tool.schema.ts index 8fd92a4592f..5740c2e9538 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-tool.schema.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-tool.schema.ts @@ -1,4 +1,4 @@ -import { isValidUuid } from 'twenty-shared/utils'; +import { emailDocumentSchema, isValidUuid } from 'twenty-shared/utils'; import { workflowFileSchema } from 'twenty-shared/workflow'; import { z } from 'zod'; @@ -24,7 +24,14 @@ 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 in HTML format'), + // The document is the same format campaign bodies use, so one authoring + // format serves 1:1 email and campaigns; it is rendered to email-safe + // HTML server-side. The HTML string form is kept for compatibility. + body: z + .union([emailDocumentSchema, z.string()]) + .describe( + 'The email body. Preferred: a structured email document ({type: "doc", content: [...]} with paragraph, heading, bulletList/orderedList, image, emailButton, emailSection, emailDivider and emailHtml blocks), rendered to email-safe HTML server-side. An HTML string is also accepted. Campaign-style {{variables}} are not substituted in 1:1 emails.', + ), connectedAccountId: z .string() .refine((val) => isValidUuid(val)) diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type.ts index efa21b1d4c3..255bcf7a8a2 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/types/compose-email-params.type.ts @@ -1,4 +1,5 @@ import { type EmailAttachment } from 'twenty-shared/types'; +import { type EmailDocument } from 'twenty-shared/utils'; export type ComposeEmailParams = { recipients: { @@ -7,7 +8,7 @@ export type ComposeEmailParams = { bcc?: string; }; subject: string; - body: string; + body: string | EmailDocument; connectedAccountId?: string; files?: Array; inReplyTo?: string; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/utils/__tests__/render-email-body.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/utils/__tests__/render-email-body.util.spec.ts new file mode 100644 index 00000000000..17f08172ccd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/utils/__tests__/render-email-body.util.spec.ts @@ -0,0 +1,42 @@ +import { renderEmailBodyToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-email-body.util'; + +jest.mock( + 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util', + () => ({ + renderRichTextToHtml: jest.fn().mockResolvedValue('

rendered html

'), + }), +); + +const { renderRichTextToHtml } = jest.requireMock( + 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util', +); + +describe('renderEmailBodyToHtml', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass an HTML string through untouched', async () => { + const html = '

Hello there

'; + + await expect(renderEmailBodyToHtml(html)).resolves.toBe(html); + expect(renderRichTextToHtml).not.toHaveBeenCalled(); + }); + + it('should render an email document through the shared renderer', async () => { + const document = { + type: 'doc' as const, + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }; + + await expect(renderEmailBodyToHtml(document)).resolves.toBe( + '

rendered html

', + ); + expect(renderRichTextToHtml).toHaveBeenCalledWith(document); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/utils/render-email-body.util.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/utils/render-email-body.util.ts new file mode 100644 index 00000000000..697be054999 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/utils/render-email-body.util.ts @@ -0,0 +1,17 @@ +import { type JSONContent } from 'twenty-emails'; +import { type EmailDocument } from 'twenty-shared/utils'; + +import { renderRichTextToHtml } from 'src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util'; + +// 1:1 email bodies and campaign bodies share one authoring format: the email +// document. A document renders through the same renderer campaigns use; a +// plain string is treated as ready-made HTML for compatibility. +export const renderEmailBodyToHtml = async ( + body: string | EmailDocument, +): Promise => { + if (typeof body === 'string') { + return body; + } + + return renderRichTextToHtml(body as JSONContent); +};