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:
Harshit Singh
2025-10-22 22:15:28 +05:30
committed by GitHub
parent e9ab54766c
commit 4b846a42a2
16 changed files with 567 additions and 117 deletions
@@ -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'),
},
});
});
});
@@ -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'