mirror of
https://github.com/twentyhq/twenty.git
synced 2026-07-31 17:48:56 -04:00
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:
@@ -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 || '');
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user