Accept the email document format in send_email and draft_email bodies

The 1:1 email tools stop being HTML-only: body now takes the same
structured email document campaigns use (preferred) or an HTML string
(kept for compatibility). Documents render through the shared
twenty-emails renderer before the existing sanitization, so campaigns,
workflow emails and agent 1:1 emails all author in one format and
render through one code path.
This commit is contained in:
Félix Malfait
2026-07-31 20:46:35 +00:00
parent 46f4aece9d
commit bbad564fcd
5 changed files with 73 additions and 4 deletions

View File

@@ -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 || '');

View File

@@ -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))

View File

@@ -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<EmailAttachment>;
inReplyTo?: string;

View File

@@ -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('<p>rendered html</p>'),
}),
);
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 = '<p>Hello <strong>there</strong></p>';
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(
'<p>rendered html</p>',
);
expect(renderRichTextToHtml).toHaveBeenCalledWith(document);
});
});

View File

@@ -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<string> => {
if (typeof body === 'string') {
return body;
}
return renderRichTextToHtml(body as JSONContent);
};