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
@@ -25,6 +25,7 @@ import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.work
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { TaskWorkspaceEntity } from 'src/modules/task/standard-objects/task.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@WorkspaceEntity({
@@ -183,6 +184,22 @@ export class AttachmentWorkspaceEntity extends BaseWorkspaceEntity {
})
dashboardId: string | null;
@WorkspaceRelation({
standardId: ATTACHMENT_STANDARD_FIELD_IDS.workflow,
type: RelationType.MANY_TO_ONE,
label: msg`Workflow`,
description: msg`Attachment workflow`,
icon: 'IconSettingsAutomation',
inverseSideTarget: () => WorkflowWorkspaceEntity,
inverseSideFieldKey: 'attachments',
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsNullable()
workflow: Relation<WorkflowWorkspaceEntity> | null;
@WorkspaceJoinColumn('workflow')
workflowId: string | null;
@WorkspaceDynamicRelation({
type: RelationType.MANY_TO_ONE,
argsFactory: (oppositeObjectMetadata) => ({
@@ -0,0 +1,147 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/oauth2-client.provider';
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
describe('MessagingSendMessageService - Gmail HTML Support', () => {
let service: MessagingSendMessageService;
let gmailClientProvider: jest.Mocked<GmailClientProvider>;
let oAuth2ClientProvider: jest.Mocked<OAuth2ClientProvider>;
beforeEach(async () => {
const mockGmailClient = {
users: {
messages: {
send: jest.fn().mockResolvedValue({ data: { id: 'message-id' } }),
},
},
};
const mockOAuth2Client = {
userinfo: {
get: jest.fn().mockResolvedValue({
data: { email: 'test@example.com', name: 'Test User' },
}),
},
};
const module: TestingModule = await Test.createTestingModule({
providers: [
MessagingSendMessageService,
{
provide: GmailClientProvider,
useValue: {
getGmailClient: jest.fn().mockResolvedValue(mockGmailClient),
},
},
{
provide: OAuth2ClientProvider,
useValue: {
getOAuth2Client: jest.fn().mockResolvedValue(mockOAuth2Client),
},
},
{
provide: MicrosoftClientProvider,
useValue: {},
},
{
provide: SmtpClientProvider,
useValue: {},
},
],
}).compile();
service = module.get<MessagingSendMessageService>(
MessagingSendMessageService,
);
gmailClientProvider = module.get(GmailClientProvider);
oAuth2ClientProvider = module.get(OAuth2ClientProvider);
});
it('should send multipart/alternative email with both text and HTML parts via Gmail', async () => {
const sendMessageInput = {
to: 'recipient@example.com',
subject: 'Test HTML Email',
body: 'This is plain text content',
html: '<p>This is <strong>HTML</strong> content</p>',
};
const connectedAccount = {
provider: ConnectedAccountProvider.GOOGLE,
accessToken: 'access-token',
refreshToken: 'refresh-token',
} as any;
await service.sendMessage(sendMessageInput, connectedAccount);
const gmailClient =
await gmailClientProvider.getGmailClient(connectedAccount);
const sendCall = gmailClient.users.messages.send as jest.Mock;
expect(sendCall).toHaveBeenCalledTimes(1);
const sentMessage = sendCall.mock.calls[0][0];
const rawMessage = Buffer.from(
sentMessage.requestBody.raw,
'base64',
).toString();
expect(rawMessage).toContain('MIME-Version: 1.0');
expect(rawMessage).toContain(
'Content-Type: multipart/alternative; boundary=',
);
expect(rawMessage).toContain('Content-Type: text/plain; charset="UTF-8"');
expect(rawMessage).toContain('Content-Type: text/html; charset="UTF-8"');
expect(rawMessage).toContain('This is plain text content');
expect(rawMessage).toContain(
'<p>This is <strong>HTML</strong> content</p>',
);
expect(rawMessage).toContain('To: recipient@example.com');
expect(rawMessage).toContain('Subject:');
});
it('should handle missing fromName gracefully', async () => {
const mockOAuth2ClientNoName = {
userinfo: {
get: jest.fn().mockResolvedValue({
data: { email: 'test@example.com' }, // No name field
}),
},
};
(oAuth2ClientProvider.getOAuth2Client as jest.Mock).mockResolvedValueOnce(
mockOAuth2ClientNoName,
);
const sendMessageInput = {
to: 'recipient@example.com',
subject: 'Test Email',
body: 'Plain text',
html: '<p>HTML content</p>',
};
const connectedAccount = {
provider: ConnectedAccountProvider.GOOGLE,
accessToken: 'access-token',
refreshToken: 'refresh-token',
} as any;
await service.sendMessage(sendMessageInput, connectedAccount);
const gmailClient =
await gmailClientProvider.getGmailClient(connectedAccount);
const sendCall = gmailClient.users.messages.send as jest.Mock;
const rawMessage = Buffer.from(
sendCall.mock.calls[0][0].requestBody.raw,
'base64',
).toString();
expect(rawMessage).toContain('From: test@example.com');
expect(rawMessage).not.toContain('""');
});
});
@@ -12,14 +12,15 @@ import {
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/oauth2-client.provider';
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { isAccessTokenRefreshingError } from 'src/modules/messaging/message-import-manager/drivers/microsoft/utils/is-access-token-refreshing-error.utils';
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
interface SendMessageInput {
body: string;
subject: string;
to: string;
html: string;
}
@Injectable()
@@ -46,26 +47,37 @@ export class MessagingSendMessageService {
const { data } = await oAuth2Client.userinfo.get();
const fromEmail = data.email;
const fromName = data.name;
const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const headers: string[] = [];
if (isDefined(fromName)) {
headers.push(`From: "${mimeEncode(fromName)}" <${fromEmail}>`);
} else {
headers.push(`From: ${fromEmail}`);
}
headers.push(
`To: ${sendMessageInput.to}`,
`Subject: ${mimeEncode(sendMessageInput.subject)}`,
'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/plain; charset="UTF-8"',
'',
sendMessageInput.body,
'',
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'',
sendMessageInput.html,
'',
`--${boundary}--`,
);
const message = headers.join('\n');
const encodedMessage = Buffer.from(message).toString('base64');
await gmailClient.users.messages.send({
@@ -85,8 +97,8 @@ export class MessagingSendMessageService {
const message = {
subject: sendMessageInput.subject,
body: {
contentType: 'Text',
content: sendMessageInput.body,
contentType: 'HTML',
content: sendMessageInput.html,
},
toRecipients: [{ emailAddress: { address: sendMessageInput.to } }],
};
@@ -130,6 +142,7 @@ export class MessagingSendMessageService {
to: sendMessageInput.to,
subject: sendMessageInput.subject,
text: sendMessageInput.body,
html: sendMessageInput.html,
});
break;
}
@@ -0,0 +1,61 @@
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
describe('Gmail MIME Message Format', () => {
it('should create valid multipart/alternative MIME structure', () => {
const sendMessageInput = {
to: 'test@example.com',
subject: 'Test Subject',
body: 'Plain text content',
html: '<p>HTML content</p>',
};
const fromEmail = 'sender@example.com';
const fromName = 'Test Sender';
const boundary = 'boundary_test_123';
const headers: string[] = [];
headers.push(`From: "${mimeEncode(fromName)}" <${fromEmail}>`);
headers.push(
`To: ${sendMessageInput.to}`,
`Subject: ${mimeEncode(sendMessageInput.subject)}`,
'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/plain; charset="UTF-8"',
'',
sendMessageInput.body,
'',
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'',
sendMessageInput.html,
'',
`--${boundary}--`,
);
const message = headers.join('\n');
expect(message).toContain('MIME-Version: 1.0');
expect(message).toContain('Content-Type: multipart/alternative');
expect(message).toContain('Content-Type: text/plain; charset="UTF-8"');
expect(message).toContain('Content-Type: text/html; charset="UTF-8"');
expect(message).toContain('Plain text content');
expect(message).toContain('<p>HTML content</p>');
expect(message).toContain(`--${boundary}`);
expect(message).toContain(`--${boundary}--`);
});
it('should handle missing fromName gracefully', () => {
const fromEmail = 'sender@example.com';
const headers: string[] = [];
headers.push(`From: ${fromEmail}`);
const message = headers.join('\n');
expect(message).toContain(`From: ${fromEmail}`);
expect(message).not.toContain('""');
});
});
@@ -24,6 +24,7 @@ import {
type FieldTypeAndNameMetadata,
getTsVectorColumnExpressionFromFields,
} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
import { WorkflowAutomatedTriggerWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity';
@@ -193,6 +194,18 @@ export class WorkflowWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsSystem()
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
@WorkspaceRelation({
standardId: WORKFLOW_STANDARD_FIELD_IDS.attachments,
type: RelationType.ONE_TO_MANY,
label: msg`Attachments`,
description: msg`Attachments linked to the workflow`,
icon: 'IconFileUpload',
inverseSideTarget: () => AttachmentWorkspaceEntity,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsSystem()
attachments: Relation<AttachmentWorkspaceEntity[]>;
@WorkspaceField({
standardId: WORKFLOW_STANDARD_FIELD_IDS.createdBy,
type: FieldMetadataType.ACTOR,