feat: Attachement for Send Email workflow node (#15044)
## Description - This PR addresses the one issue out of https://github.com/twentyhq/core-team-issues/issues/1685 - Added backend support for workflow node to support attachement - updated send email schema, core utility - added workflowattachmentRow and workflowsendEmailAttachment file to handle file attachment in email workflow - updated Google and Microsoft to use MailComposer which unifies with SMTP provider and improves mail structure ## Visual Appearance https://github.com/user-attachments/assets/16478569-0a83-417e-a85e-70e41fe83343 --------- Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
+68
-80
@@ -7,28 +7,50 @@ import { ImapClientProvider } from 'src/modules/messaging/message-import-manager
|
||||
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';
|
||||
|
||||
jest.mock('nodemailer/lib/mail-composer', () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
compile: jest.fn().mockReturnValue({
|
||||
build: jest.fn().mockResolvedValue(Buffer.from('mocked-email-content')),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
describe('MessagingSendMessageService - Gmail HTML Support', () => {
|
||||
let service: MessagingSendMessageService;
|
||||
let oAuth2ClientManagerService: OAuth2ClientManagerService;
|
||||
|
||||
const mockSend = jest.fn().mockResolvedValue({ data: { id: 'message-id' } });
|
||||
|
||||
const mockGmailClient = {
|
||||
users: {
|
||||
messages: {
|
||||
send: mockSend,
|
||||
},
|
||||
getProfile: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: { emailAdress: 'test@example.com' } }),
|
||||
},
|
||||
};
|
||||
|
||||
const mockPeopleClient = {
|
||||
people: {
|
||||
get: jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
names: [
|
||||
{
|
||||
displayName: 'Test User',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const mockOAuth2Client = {
|
||||
gmail: jest.fn().mockReturnValue(mockGmailClient),
|
||||
people: jest.fn().mockReturnValue(mockPeopleClient),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockGmailClient = {
|
||||
users: {
|
||||
messages: {
|
||||
send: jest.fn().mockResolvedValue({ data: { id: 'message-id' } }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockOAuth2Client = {
|
||||
gmail: jest.fn().mockReturnValue(mockGmailClient),
|
||||
userinfo: {
|
||||
get: jest.fn().mockResolvedValue({
|
||||
data: { email: 'test@example.com', name: 'Test User' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MessagingSendMessageService,
|
||||
@@ -54,9 +76,10 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
|
||||
service = module.get<MessagingSendMessageService>(
|
||||
MessagingSendMessageService,
|
||||
);
|
||||
oAuth2ClientManagerService = module.get<OAuth2ClientManagerService>(
|
||||
OAuth2ClientManagerService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should send multipart/alternative email with both text and HTML parts via Gmail', async () => {
|
||||
@@ -65,6 +88,7 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
|
||||
subject: 'Test HTML Email',
|
||||
body: 'This is plain text content',
|
||||
html: '<p>This is <strong>HTML</strong> content</p>',
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
const connectedAccount = {
|
||||
@@ -75,60 +99,28 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
|
||||
|
||||
await service.sendMessage(sendMessageInput, connectedAccount);
|
||||
|
||||
const mockOAuth2Client =
|
||||
await oAuth2ClientManagerService.getGoogleOAuth2Client(connectedAccount);
|
||||
const gmailClient = mockOAuth2Client.gmail({ version: 'v1' });
|
||||
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:');
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
expect(mockSend).toHaveBeenCalledWith({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
raw: Buffer.from('mocked-email-content').toString('base64'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing fromName gracefully', async () => {
|
||||
const mockGmailClient = {
|
||||
users: {
|
||||
messages: {
|
||||
send: jest.fn().mockResolvedValue({ data: { id: 'message-id' } }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockOAuth2ClientNoName = {
|
||||
gmail: jest.fn().mockReturnValue(mockGmailClient),
|
||||
userinfo: {
|
||||
get: jest.fn().mockResolvedValue({
|
||||
data: { email: 'test@example.com' }, // No name field
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
(
|
||||
oAuth2ClientManagerService.getGoogleOAuth2Client as jest.Mock
|
||||
).mockResolvedValueOnce(mockOAuth2ClientNoName);
|
||||
|
||||
it('should send email with attachments via Gmail', async () => {
|
||||
const sendMessageInput = {
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test Email',
|
||||
subject: 'Test Email with Attachments',
|
||||
body: 'Plain text',
|
||||
html: '<p>HTML content</p>',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'test.pdf',
|
||||
content: Buffer.from('test-pdf-content'),
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const connectedAccount = {
|
||||
@@ -139,16 +131,12 @@ describe('MessagingSendMessageService - Gmail HTML Support', () => {
|
||||
|
||||
await service.sendMessage(sendMessageInput, connectedAccount);
|
||||
|
||||
const sendCall = mockGmailClient.users.messages.send as jest.Mock;
|
||||
|
||||
expect(sendCall).toHaveBeenCalledTimes(1);
|
||||
|
||||
const rawMessage = Buffer.from(
|
||||
sendCall.mock.calls[0][0].requestBody.raw,
|
||||
'base64',
|
||||
).toString();
|
||||
|
||||
expect(rawMessage).toContain('From: test@example.com');
|
||||
expect(rawMessage).not.toContain('""');
|
||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
||||
expect(mockSend).toHaveBeenCalledWith({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
raw: Buffer.from('mocked-email-content').toString('base64'),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+60
-30
@@ -21,6 +21,11 @@ interface SendMessageInput {
|
||||
subject: string;
|
||||
to: string;
|
||||
html: string;
|
||||
attachments?: {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -46,41 +51,45 @@ export class MessagingSendMessageService {
|
||||
version: 'v1',
|
||||
});
|
||||
|
||||
const { data } = await oAuth2Client.userinfo.get();
|
||||
const peopleClient = oAuth2Client.people({
|
||||
version: 'v1',
|
||||
});
|
||||
|
||||
const fromEmail = data.email;
|
||||
const fromName = data.name;
|
||||
const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const { data: gmailData } = await gmailClient.users.getProfile({
|
||||
userId: 'me',
|
||||
});
|
||||
|
||||
const headers: string[] = [];
|
||||
const fromEmail = gmailData.emailAddress;
|
||||
|
||||
if (isDefined(fromName)) {
|
||||
headers.push(`From: "${mimeEncode(fromName)}" <${fromEmail}>`);
|
||||
} else {
|
||||
headers.push(`From: ${fromEmail}`);
|
||||
}
|
||||
const { data: peopleData } = await peopleClient.people.get({
|
||||
resourceName: 'people/me',
|
||||
personFields: 'names',
|
||||
});
|
||||
|
||||
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 fromName = peopleData?.names?.[0]?.displayName;
|
||||
|
||||
const message = headers.join('\n');
|
||||
const encodedMessage = Buffer.from(message).toString('base64');
|
||||
const mail = new MailComposer({
|
||||
from: isDefined(fromName)
|
||||
? `"${mimeEncode(fromName)}" <${fromEmail}>`
|
||||
: `${fromEmail}`,
|
||||
to: sendMessageInput.to,
|
||||
subject: sendMessageInput.subject,
|
||||
text: sendMessageInput.body,
|
||||
html: sendMessageInput.html,
|
||||
...(sendMessageInput.attachments &&
|
||||
sendMessageInput.attachments.length > 0
|
||||
? {
|
||||
attachments: sendMessageInput.attachments.map((attachment) => ({
|
||||
filename: attachment.filename,
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const messageBuffer = await mail.compile().build();
|
||||
const encodedMessage = Buffer.from(messageBuffer).toString('base64');
|
||||
|
||||
await gmailClient.users.messages.send({
|
||||
userId: 'me',
|
||||
@@ -103,6 +112,17 @@ export class MessagingSendMessageService {
|
||||
content: sendMessageInput.html,
|
||||
},
|
||||
toRecipients: [{ emailAddress: { address: sendMessageInput.to } }],
|
||||
...(sendMessageInput.attachments &&
|
||||
sendMessageInput.attachments.length > 0
|
||||
? {
|
||||
attachments: sendMessageInput.attachments.map((attachment) => ({
|
||||
'@odata.type': '#microsoft.graph.fileAttachment',
|
||||
name: attachment.filename,
|
||||
contentType: attachment.contentType,
|
||||
contentBytes: attachment.content.toString('base64'),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const response = await microsoftClient
|
||||
@@ -148,6 +168,16 @@ export class MessagingSendMessageService {
|
||||
subject: sendMessageInput.subject,
|
||||
text: sendMessageInput.body,
|
||||
html: sendMessageInput.html,
|
||||
...(sendMessageInput.attachments &&
|
||||
sendMessageInput.attachments.length > 0
|
||||
? {
|
||||
attachments: sendMessageInput.attachments.map((attachment) => ({
|
||||
filename: attachment.filename,
|
||||
content: attachment.content,
|
||||
contentType: attachment.contentType,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const messageBuffer = await mail.compile().build();
|
||||
|
||||
@@ -21,6 +21,12 @@ export type Message = Omit<
|
||||
direction: MessageDirection;
|
||||
};
|
||||
|
||||
export type MessageAttachment = {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
export type MessageParticipant = Omit<
|
||||
MessageParticipantWorkspaceEntity,
|
||||
| 'id'
|
||||
|
||||
Reference in New Issue
Block a user