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:
+17
@@ -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) => ({
|
||||
|
||||
+147
@@ -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('""');
|
||||
});
|
||||
});
|
||||
+18
-5
@@ -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;
|
||||
}
|
||||
|
||||
+61
@@ -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('""');
|
||||
});
|
||||
});
|
||||
+13
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user