feat: rich text email body (#14482)

Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Arik Chakma
2025-09-21 20:55:34 +06:00
committed by GitHub
parent a89e5d0f27
commit 9a2766feae
66 changed files with 3312 additions and 276 deletions
@@ -80,8 +80,8 @@ export class ApprovedAccessDomainService {
serverUrl: this.twentyConfigService.get('SERVER_URL'),
locale: sender.locale,
});
const html = render(emailTemplate);
const text = render(emailTemplate, {
const html = await render(emailTemplate);
const text = await render(emailTemplate, {
plainText: true,
});
@@ -18,6 +18,17 @@ import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-membe
import { ApprovedAccessDomainService } from './approved-access-domain.service';
// To avoid dynamic import issues in Jest
jest.mock('@react-email/render', () => ({
render: jest.fn().mockImplementation(async (template, options) => {
if (options?.plainText) {
return 'Plain Text Email';
}
return '<html><body>HTML email content</body></html>';
}),
}));
describe('ApprovedAccessDomainService', () => {
let service: ApprovedAccessDomainService;
let approvedAccessDomainRepository: Repository<ApprovedAccessDomain>;
@@ -482,8 +482,8 @@ export class AuthService {
locale: firstUserWorkspace.locale,
});
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const passwordChangedMsg = msg`Your Password Has Been Successfully Changed`;
const i18n = this.i18nService.getI18nInstance(firstUserWorkspace.locale);
@@ -18,6 +18,17 @@ import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { ResetPasswordService } from './reset-password.service';
// To avoid dynamic import issues in Jest
jest.mock('@react-email/render', () => ({
render: jest.fn().mockImplementation(async (template, options) => {
if (options?.plainText) {
return 'Plain Text Email';
}
return '<html><body>HTML email content</body></html>';
}),
}));
describe('ResetPasswordService', () => {
let service: ResetPasswordService;
let userRepository: Repository<User>;
@@ -163,8 +163,8 @@ export class ResetPasswordService {
const emailTemplate = PasswordResetLinkEmail(emailData);
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const resetPasswordMsg = msg`Action Needed to Reset Password`;
const i18n = this.i18nService.getI18nInstance(locale);
@@ -80,8 +80,8 @@ export class EmailVerificationService {
const emailTemplate = SendEmailVerificationLinkEmail(emailData);
const html = render(emailTemplate);
const text = render(emailTemplate, {
const html = await render(emailTemplate);
const text = await render(emailTemplate, {
plainText: true,
});
@@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { render, toPlainText } from '@react-email/render';
import DOMPurify from 'dompurify';
import { reactMarkupFromJSON } from 'twenty-emails';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { z } from 'zod';
@@ -16,6 +18,7 @@ import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/s
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
import { parseEmailBody } from 'src/utils/parse-email-body';
@Injectable()
export class SendEmailTool implements Tool {
@@ -115,17 +118,23 @@ export class SendEmailTool implements Tool {
workspaceId,
);
const parsedBody = parseEmailBody(body);
const reactMarkup = reactMarkupFromJSON(parsedBody);
const htmlBody = await render(reactMarkup);
const textBody = toPlainText(htmlBody);
const { JSDOM } = await import('jsdom');
const window = new JSDOM('').window;
const purify = DOMPurify(window);
const safeBody = purify.sanitize(body || '');
const safeHtmlBody = purify.sanitize(htmlBody || '');
const safeSubject = purify.sanitize(subject || '');
await this.sendMessageService.sendMessage(
{
to: email,
subject: safeSubject,
body: safeBody,
body: textBody,
html: safeHtmlBody,
},
connectedAccount,
);
@@ -23,6 +23,17 @@ import { WorkspaceInvitationService } from './workspace-invitation.service';
// To fix a circular dependency issue
jest.mock('src/engine/core-modules/workspace/services/workspace.service');
// To avoid dynamic import issues in Jest
jest.mock('@react-email/render', () => ({
render: jest.fn().mockImplementation(async (template, options) => {
if (options?.plainText) {
return 'Plain Text Email';
}
return '<html><body>HTML email content</body></html>';
}),
}));
describe('WorkspaceInvitationService', () => {
let service: WorkspaceInvitationService;
let appTokenRepository: Repository<AppToken>;
@@ -305,8 +305,8 @@ export class WorkspaceInvitationService {
};
const emailTemplate = SendInviteLinkEmail(emailData);
const html = render(emailTemplate);
const text = render(emailTemplate, {
const html = await render(emailTemplate);
const text = await render(emailTemplate, {
plainText: true,
});
@@ -123,8 +123,8 @@ export class CleanerWorkspaceService {
locale: workspaceMember.locale,
};
const emailTemplate = WarnSuspendedWorkspaceEmail(emailData);
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
const workspaceDeletionMsg = msg`Action needed to prevent workspace deletion`;
const i18n = this.i18nService.getI18nInstance(workspaceMember.locale);
@@ -201,8 +201,8 @@ export class CleanerWorkspaceService {
locale: workspaceMember.locale,
};
const emailTemplate = CleanSuspendedWorkspaceEmail(emailData);
const html = render(emailTemplate, { pretty: true });
const text = render(emailTemplate, { plainText: true });
const html = await render(emailTemplate, { pretty: true });
const text = await render(emailTemplate, { plainText: true });
this.emailService.send({
to: workspaceMember.userEmail,
@@ -41,6 +41,7 @@ export const ATTACHMENT_STANDARD_FIELD_IDS = {
company: '20202020-ceab-4a28-b546-73b06b4c08d5',
opportunity: '20202020-7374-499d-bea3-9354890755b5',
dashboard: '20202020-5324-43f3-9dbf-1a33e7de0ce6',
workflow: '20202020-f1e8-4c9d-8a7b-3f5e1d2c9a8b',
custom: '20202020-302d-43b3-9aea-aa4f89282a9f',
} as const;
@@ -464,6 +465,7 @@ export const WORKFLOW_STANDARD_FIELD_IDS = {
automatedTriggers: '20202020-3319-4234-a34c-117ecad2b8a9',
favorites: '20202020-c554-4c41-be7a-cf9cd4b0d512',
timelineActivities: '20202020-906e-486a-a798-131a5f081faf',
attachments: '20202020-4a8c-4e2d-9b1c-7e5f3a2b4c6d',
createdBy: '20202020-6007-401a-8aa5-e6f48581a6f3',
searchVector: '20202020-535d-4ffa-b7f3-4fa0d5da1b7a',
} as const;