[Outlook integration] part 2 : GetMessages (#9612)

### Introducing

- mock files in order to setup unit test on parsing outlook messages
- special spec files for development purposes : dev.spec files. They are
CI skipped with xdescribe but very useful for iterating on new messages
format
- main functionality : getMessages. We use microsoft default client to
do so, using the $batch endpoint to group calls by 20

### documentation
final touch to add troubleshooting tips
This commit is contained in:
Guillim
2025-01-15 09:48:57 +01:00
committed by GitHub
parent eaa68424f5
commit fc484bde2d
15 changed files with 867 additions and 27 deletions

View File

@ -17,6 +17,8 @@ export class EmailAliasManagerService {
let handleAliases: string[];
switch (connectedAccount.provider) {
case 'microsoft':
return;
case 'google':
handleAliases =
await this.googleEmailAliasManagerService.getHandleAliases(

View File

@ -20,6 +20,7 @@ export class RefreshAccessTokenService {
workspaceId: string,
): Promise<string> {
const refreshToken = connectedAccount.refreshToken;
let accessToken: string;
if (!refreshToken) {
throw new RefreshAccessTokenException(
@ -28,33 +29,39 @@ export class RefreshAccessTokenService {
);
}
let accessToken: string;
switch (connectedAccount.provider) {
case 'microsoft':
return '';
case 'google': {
try {
accessToken = await this.refreshAccessToken(
connectedAccount,
refreshToken,
);
} catch (error) {
throw new RefreshAccessTokenException(
`Error refreshing access token for connected account ${connectedAccount.id} in workspace ${workspaceId}: ${error.message}`,
RefreshAccessTokenExceptionCode.REFRESH_ACCESS_TOKEN_FAILED,
);
}
try {
accessToken = await this.refreshAccessToken(
connectedAccount,
refreshToken,
);
} catch (error) {
throw new RefreshAccessTokenException(
`Error refreshing access token for connected account ${connectedAccount.id} in workspace ${workspaceId}: ${error.message}`,
RefreshAccessTokenExceptionCode.REFRESH_ACCESS_TOKEN_FAILED,
);
const connectedAccountRepository =
await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>(
'connectedAccount',
);
await connectedAccountRepository.update(
{ id: connectedAccount.id },
{
accessToken,
},
);
return accessToken;
}
default:
throw new Error('Provider not supported for access token refresh');
}
const connectedAccountRepository =
await this.twentyORMManager.getRepository<ConnectedAccountWorkspaceEntity>(
'connectedAccount',
);
await connectedAccountRepository.update(
{ id: connectedAccount.id },
{
accessToken,
},
);
return accessToken;
}
async refreshAccessToken(

View File

@ -8,6 +8,9 @@ import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-accou
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
import { MicrosoftFetchByBatchService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-fetch-by-batch.service';
import { MicrosoftGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service';
import { MicrosoftHandleErrorService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-handle-error.service';
import { MicrosoftGetMessageListService } from './services/microsoft-get-message-list.service';
@ -23,8 +26,15 @@ import { MicrosoftGetMessageListService } from './services/microsoft-get-message
providers: [
MicrosoftClientProvider,
MicrosoftGetMessageListService,
MicrosoftGetMessagesService,
MicrosoftFetchByBatchService,
MicrosoftHandleErrorService,
MicrosoftOAuth2ClientManagerService,
],
exports: [MicrosoftGetMessageListService, MicrosoftClientProvider],
exports: [
MicrosoftGetMessageListService,
MicrosoftClientProvider,
MicrosoftGetMessagesService,
],
})
export class MessagingMicrosoftDriverModule {}

View File

@ -0,0 +1,241 @@
export const microsoftGraphWithMessages = {
'@odata.context':
'https://graph.microsoft.com/beta/$metadata#Collection(message)',
value: [
{
'@odata.type': '#microsoft.graph.message',
'@odata.etag': 'W/"CQAAABYAAAAadQ+1xAL8SLCZzf1KYyk+AAACItFa"',
id: 'AAMkAGZlMDQ1NjU5LTUzN2UtNDAyMC1hNmVlLTZhZmExMGU3ZDU1NwBGAAAAAADzAhgkpMbwQYnkXH1D-Va3BwAadQ_1xAL8SLCZzf1KYyk_AAAAAAEMAAAadQ_1xAL8SLCZzf1KYyk_AAACJSmSAAA=',
},
],
'@odata.nextLink':
"https://graph.microsoft.com/beta/me/mailFolders('inbox')/messages/delta?$skiptoken=jWnSM_TVmEdmKBzfVjDdNbDwpt3yYSUqEf9CFdhRcTxhbogC9oaTvY1ZdONMplHuz0pwtPay_qkEcFQ5RLEuDZ3O6IgnI5FXRcfekzOECWlL7zRVdGBidZ5TkXmXV7O7P8cxtvBMFJ2_dV951teFMatpdnD6hvksBK0Ff4tJKfo.HvZwAw_DM9PR3xf90ThtbqSdMCkGCHNPkjpaedxSBN3",
};
export const microsoftGraphBatchWithTwoMessagesResponse = [
{
responses: [
{
id: '2',
status: 200,
headers: {
'Cache-Control': 'private',
'Content-Type':
'application/json; odata.metadata=minimal; odata.streaming=true; IEEE754Compatible=false; charset=utf-8',
},
body: {
'@odata.context':
"https://graph.microsoft.com/v1.0/$metadata#users('599cbaf7-873d-4b6f-b374-f87d3605e9e0')/messages/$entity",
'@odata.etag': 'W/"CQAAABYAAAAadQ+1xAL8SLCZzf1KYyk+AAACItFa"',
id: 'AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0AGnUPtcQC-Eiwmc39SmMpPgAAAiVYkAAA',
createdDateTime: '2025-01-10T13:31:37Z',
lastModifiedDateTime: '2025-01-10T13:31:38Z',
changeKey: 'CQAAABYAAAAadQ+1xAL8SLCZzf1KYyk+AAACItFa',
categories: [],
receivedDateTime: '2025-01-10T13:31:37Z',
sentDateTime: '2025-01-10T13:31:34Z',
hasAttachments: true,
internetMessageId:
'<FRZP194MB2383FF1CFE426952F85B1110981C3@FRAP194MB2383.EURP194.PROD.OUTLOOK.COM>',
subject: 'test email John: number 4',
bodyPreview: 'test 4',
importance: 'normal',
parentFolderId:
'AAMkAGZlMDQ1NjU5LTUzN2UtNDAyMC1hNmVlLTZhZmExMGU3ZDU1NwAuAAAAAADzAhgkpMbwQYnkXH1D-Va3AQAadQ_1xAL8SLCZzf1KYyk_AAAAAAEMAAA=',
conversationId:
'AAQkAGZlMDQ1NjU5LTUzN2UtNDAyMC1hNmVlLTZhZmExMGU3ZDU1NwAQAAZhOZ86nXZElRkxyGJRiY8=',
conversationIndex: 'AQHbY2PrBmE5nzqddkSVGTHIYlGJjw==',
isDeliveryReceiptRequested: null,
isReadReceiptRequested: false,
isRead: false,
isDraft: false,
webLink:
'https://outlook.office365.com/owa/?ItemID=AAkALgAAAAAAHYQDEapmEc2byACqAC%2FEWg0AGnUPtcQC%2FEiwmc39SmMpPgAAAiVYkAAA&exvsurl=1&viewmodel=ReadMessageItem',
inferenceClassification: 'focused',
body: {
contentType: 'text',
content: 'plain text format test 4',
},
sender: {
emailAddress: {
name: 'John l',
address: 'John.l@outlook.fr',
},
},
from: {
emailAddress: {
name: 'John l',
address: 'John.l@outlook.fr',
},
},
toRecipients: [
{
emailAddress: {
name: 'Walker',
address: 'walker@felixacme.onmicrosoft.com',
},
},
],
ccRecipients: [],
bccRecipients: [],
replyTo: [],
flag: {
flagStatus: 'notFlagged',
},
},
},
{
id: '1',
status: 200,
headers: {
'Cache-Control': 'private',
'Content-Type':
'application/json; odata.metadata=minimal; odata.streaming=true; IEEE754Compatible=false; charset=utf-8',
},
body: {
'@odata.context':
"https://graph.microsoft.com/v1.0/$metadata#users('599cbaf7-873d-4b6f-b374-f87d3605e9e0')/messages/$entity",
'@odata.etag': 'W/"CQAAABYAAAAadQ+1xAL8SLCZzf1KYyk+AAADw4Em"',
id: 'AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0AGnUPtcQC-Eiwmc39SmMpPgAAA8ZAfgAA',
createdDateTime: '2025-01-13T09:38:06Z',
lastModifiedDateTime: '2025-01-13T11:50:48Z',
changeKey: 'CQAAABYAAAAadQ+1xAL8SLCZzf1KYyk+AAADw4Em',
categories: [],
receivedDateTime: '2025-01-13T09:38:06Z',
sentDateTime: '2025-01-13T09:38:01Z',
hasAttachments: false,
internetMessageId:
'<dfe8ac36-cf4c-4842-a506-034548452966@az.westus2.microsoft.com>',
subject: 'test subject',
bodyPreview: 'You now have 2 licenses',
importance: 'normal',
parentFolderId:
'AAMkAGZlMDQ1NjU5LTUzN2UtNDAyMC1hNmVlLTZhZmExMGU3ZDU1NwAuAAAAAADzAhgkpMbwQYnkXH1D-Va3AQAadQ_1xAL8SLCZzf1KYyk_AAAAAAEMAAA=',
conversationId:
'AAQkAGZlMDQ1NjU5LTUzN2UtNDAyMC1hNmVlLTZhZmExMGU3ZDU1NwAQADz34qcnxpxEidnAJbZA-OI=',
conversationIndex: 'AQHbZZ7ZPPfipyfGnESJ2cAltkD84g==',
isDeliveryReceiptRequested: null,
isReadReceiptRequested: false,
isRead: false,
isDraft: false,
webLink:
'https://outlook.office365.com/owa/?ItemID=AAkALgAAAAAAHYQDEapmEc2byACqAC%2FEWg0AGnUPtcQC%2FEiwmc39SmMpPgAAA8ZAfgAA&exvsurl=1&viewmodel=ReadMessageItem',
inferenceClassification: 'focused',
body: {
contentType: 'text',
content: 'You will send a message in the plain text format',
},
sender: {
emailAddress: {
name: 'Microsoft',
address: 'microsoft-noreply@microsoft.com',
},
},
from: {
emailAddress: {
name: 'Microsoft',
address: 'microsoft-noreply@microsoft.com',
},
},
toRecipients: [
{
emailAddress: {
name: 'Walker',
address: 'walker@felixacme.onmicrosoft.com',
},
},
],
ccRecipients: [
{
emailAddress: {
name: 'Antoine',
address: 'antoine@gmail.com',
},
},
{
emailAddress: {
name: 'Cyril@acme2.com',
address: 'cyril@acme2.com',
},
},
],
bccRecipients: [],
replyTo: [],
flag: {
flagStatus: 'notFlagged',
},
},
},
],
},
];
export const microsoftGraphBatchWithHtmlMessagesResponse = [
{
responses: [
{
id: '2',
status: 200,
headers: {
'Cache-Control': 'private',
'Content-Type':
'application/json; odata.metadata=minimal; odata.streaming=true; IEEE754Compatible=false; charset=utf-8',
},
body: {
'@odata.context':
"https://graph.microsoft.com/v1.0/$metadata#users('599cbaf7-873d-4b6f-b374-f87d3605e9e0')/messages/$entity",
'@odata.etag': 'W/"CQAAABYAAAAadQ+1xAL8SLCZzf1KYyk+AAACItFa"',
id: 'AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0AGnUPtcQC-Eiwmc39SmMpPgAAAiVYkAAA',
createdDateTime: '2025-01-10T13:31:37Z',
lastModifiedDateTime: '2025-01-10T13:31:38Z',
changeKey: 'CQAAABYAAAAadQ+1xAL8SLCZzf1KYyk+AAACItFa',
categories: [],
receivedDateTime: '2025-01-10T13:31:37Z',
sentDateTime: '2025-01-10T13:31:34Z',
hasAttachments: true,
internetMessageId:
'<FRZP194MB2383FF1CFE426952F85B1110981C3@FRAP194MB2383.EURP194.PROD.OUTLOOK.COM>',
subject: 'test email John: number 5',
bodyPreview: 'test 5',
importance: 'normal',
parentFolderId:
'AAMkAGZlMDQ1NjU5LTUzN2UtNDAyMC1hNmVlLTZhZmExMGU3ZDU1NwAuAAAAAADzAhgkpMbwQYnkXH1D-Va3AQAadQ_1xAL8SLCZzf1KYyk_AAAAAAEMAAA=',
conversationId:
'AAQkAGZlMDQ1NjU5LTUzN2UtNDAyMC1hNmVlLTZhZmExMGU3ZDU1NwAQAAZhOZ86nXZElRkxyGJRiY9=',
conversationIndex: 'AQHbY2PrBmE5nzqddkSVGTHIYlGJjr==',
isDeliveryReceiptRequested: null,
isReadReceiptRequested: false,
isRead: false,
isDraft: false,
webLink:
'https://outlook.office365.com/owa/?ItemID=AAkALgAAAAAAHYQDEapmEc2byACqAC%2FEWg0AGnUPtcQC%2FEiwmc39SmMpPgAAAiVYkAAA&exvsurl=1&viewmodel=ReadMessageItem',
inferenceClassification: 'focused',
body: {
contentType: 'html',
content:
'<html><head>\r\n<meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body><div dir="ltr"><div dir="ltr"><div dir="ltr"><br></div></div><div id="divRplyFwdMsg" dir="ltr"><div>&nbsp;</div></div><div dir="ltr"><div class="x_elementToProof" style="font-family:Calibri,Helvetica,sans-serif; font-size:12pt; color:rgb(0,0,0)">test 4</div></div></div></body></html>',
},
sender: {
emailAddress: {
name: 'John l',
address: 'John.l@outlook.fr',
},
},
from: {
emailAddress: {
name: 'John l',
address: 'John.l@outlook.fr',
},
},
toRecipients: [],
ccRecipients: [],
bccRecipients: [],
replyTo: [],
flag: {
flagStatus: 'notFlagged',
},
},
},
],
},
];

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
import { MicrosoftGraphBatchResponse } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.interface';
@Injectable()
export class MicrosoftFetchByBatchService {
constructor(
private readonly microsoftClientProvider: MicrosoftClientProvider,
) {}
async fetchAllByBatches(
messageIds: string[],
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'refreshToken' | 'id'
>,
): Promise<{
messageIdsByBatch: string[][];
batchResponses: MicrosoftGraphBatchResponse[];
}> {
const batchLimit = 20;
const batchResponses: MicrosoftGraphBatchResponse[] = [];
const messageIdsByBatch: string[][] = [];
const client =
await this.microsoftClientProvider.getMicrosoftClient(connectedAccount);
for (let i = 0; i < messageIds.length; i += batchLimit) {
const batchMessageIds = messageIds.slice(i, i + batchLimit);
messageIdsByBatch.push(batchMessageIds);
const batchRequests = batchMessageIds.map((messageId, index) => ({
id: (index + 1).toString(),
method: 'GET',
url: `/me/messages/${messageId}`,
headers: {
'Content-Type': 'application/json',
Prefer: 'outlook.body-content-type="text"',
},
}));
const batchResponse = await client
.api('/$batch')
.post({ requests: batchRequests });
batchResponses.push(batchResponse);
}
return {
messageIdsByBatch,
batchResponses,
};
}
}

View File

@ -0,0 +1,57 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service';
import { ConnectedAccountProvider } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
import { MicrosoftGetMessageListService } from './microsoft-get-message-list.service';
import { MicrosoftHandleErrorService } from './microsoft-handle-error.service';
const refreshToken = 'replace-with-your-refresh-token';
xdescribe('Microsoft dev tests : get message list service', () => {
let service: MicrosoftGetMessageListService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [EnvironmentModule.forRoot({})],
providers: [
MicrosoftGetMessageListService,
MicrosoftClientProvider,
MicrosoftHandleErrorService,
MicrosoftOAuth2ClientManagerService,
ConfigService,
],
}).compile();
service = module.get<MicrosoftGetMessageListService>(
MicrosoftGetMessageListService,
);
});
const mockConnectedAccount = {
id: 'connected-account-id',
provider: ConnectedAccountProvider.MICROSOFT,
refreshToken: refreshToken,
};
it('Should fetch and return message list successfully', async () => {
const result = await service.getFullMessageList(mockConnectedAccount);
expect(result.messageExternalIds.length).toBeGreaterThan(0);
});
it('Should throw token error', async () => {
const mockConnectedAccountUnvalid = {
id: 'connected-account-id',
provider: ConnectedAccountProvider.MICROSOFT,
refreshToken: 'invalid-token',
};
await expect(
service.getFullMessageList(mockConnectedAccountUnvalid),
).rejects.toThrowError('Access token is undefined or empty');
});
});

View File

@ -35,7 +35,7 @@ export class MicrosoftGetMessageListService {
.api(syncCursor || '/me/mailfolders/inbox/messages/delta?$select=id')
.version('beta')
.headers({
Prefer: `odata.maxpagesize=${MESSAGING_MICROSOFT_USERS_MESSAGES_LIST_MAX_RESULT}`,
Prefer: `odata.maxpagesize=${MESSAGING_MICROSOFT_USERS_MESSAGES_LIST_MAX_RESULT}, IdType="ImmutableId"`,
})
.get();

View File

@ -0,0 +1,45 @@
export interface MicrosoftGraphBatchResponse {
responses: {
id: string;
status: number;
headers?: {
'Cache-Control': string;
'Content-Type': string;
};
body?: {
'@odata.context'?: string;
'@odata.etag'?: string;
id?: string;
createdDateTime?: string;
lastModifiedDateTime?: string;
changeKey?: string;
categories?: any[];
receivedDateTime?: string;
sentDateTime?: string;
hasAttachments?: boolean;
internetMessageId?: string;
subject?: string;
bodyPreview?: string;
importance?: string;
parentFolderId?: string;
conversationId?: string;
conversationIndex?: string;
isDeliveryReceiptRequested?: boolean | null;
isReadReceiptRequested?: boolean;
isRead?: boolean;
isDraft?: boolean;
webLink?: string;
inferenceClassification?: string;
body?: {
contentType?: string;
content?: string;
};
sender?: {
emailAddress?: {
name?: string;
address?: string;
};
};
};
}[];
}

View File

@ -0,0 +1,58 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service';
import { ConnectedAccountProvider } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
import { MicrosoftFetchByBatchService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-fetch-by-batch.service';
import { MicrosoftGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service';
import { MicrosoftHandleErrorService } from './microsoft-handle-error.service';
const mockMessageIds = [
'AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0AGnUPtcQC-Eiwmc39SmMpPgAAA8ZAfgAA',
'AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0AGnUPtcQC-Eiwmc39SmMpPgAAAiVYkAAA',
];
const refreshToken = 'replace-with-your-refresh-token';
xdescribe('Microsoft dev tests : get messages service', () => {
let service: MicrosoftGetMessagesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [EnvironmentModule.forRoot({})],
providers: [
MicrosoftGetMessagesService,
MicrosoftHandleErrorService,
MicrosoftClientProvider,
MicrosoftOAuth2ClientManagerService,
MicrosoftFetchByBatchService,
ConfigService,
],
}).compile();
service = module.get<MicrosoftGetMessagesService>(
MicrosoftGetMessagesService,
);
});
const mockConnectedAccount = {
id: 'connected-account-id',
provider: ConnectedAccountProvider.MICROSOFT,
handle: 'John.Walker@outlook.fr',
handleAliases: '',
refreshToken: refreshToken,
};
it('should fetch and format messages successfully', async () => {
const result = await service.getMessages(
mockMessageIds,
mockConnectedAccount,
'workspace-1',
);
expect(result).toHaveLength(1);
});
});

View File

@ -0,0 +1,174 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/drivers/microsoft/microsoft-oauth2-client-manager.service';
import { ConnectedAccountProvider } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import {
microsoftGraphBatchWithHtmlMessagesResponse,
microsoftGraphBatchWithTwoMessagesResponse,
} from 'src/modules/messaging/message-import-manager/drivers/microsoft/mocks/microsoft-api-examples';
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
import { MicrosoftFetchByBatchService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-fetch-by-batch.service';
import { MicrosoftGraphBatchResponse } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.interface';
import { MicrosoftGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service';
import { MicrosoftHandleErrorService } from './microsoft-handle-error.service';
describe('Microsoft get messages service', () => {
let service: MicrosoftGetMessagesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [EnvironmentModule.forRoot({})],
providers: [
MicrosoftGetMessagesService,
MicrosoftHandleErrorService,
MicrosoftClientProvider,
MicrosoftOAuth2ClientManagerService,
MicrosoftFetchByBatchService,
ConfigService,
],
}).compile();
service = module.get<MicrosoftGetMessagesService>(
MicrosoftGetMessagesService,
);
});
it('Should be defined', () => {
expect(service).toBeDefined();
});
it('Should format batch responses as messages', () => {
const batchResponses: MicrosoftGraphBatchResponse[] =
microsoftGraphBatchWithTwoMessagesResponse;
const connectedAccount = {
id: 'connected-account-id',
provider: ConnectedAccountProvider.MICROSOFT,
refreshToken: 'refresh-token',
handle: 'John.l@outlook.fr',
handleAliases: '',
};
const messages = service.formatBatchResponsesAsMessages(
batchResponses,
connectedAccount,
);
expect(messages).toHaveLength(2);
const responseExample1 =
microsoftGraphBatchWithTwoMessagesResponse[0].responses[0];
expect(
messages.find(
(message) =>
message.externalId ===
'AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0AGnUPtcQC-Eiwmc39SmMpPgAAAiVYkAAA',
),
).toStrictEqual({
externalId:
'AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0AGnUPtcQC-Eiwmc39SmMpPgAAAiVYkAAA',
subject: responseExample1.body.subject,
receivedAt: new Date(responseExample1.body.receivedDateTime),
text: responseExample1.body.body.content,
headerMessageId: responseExample1.body.internetMessageId,
messageThreadExternalId: responseExample1.body.conversationId,
direction: 'OUTGOING',
participants: [
{
displayName: 'John l',
handle: 'john.l@outlook.fr',
role: 'from',
},
{
displayName: 'Walker',
handle: 'walker@felixacme.onmicrosoft.com',
role: 'to',
},
],
attachments: [],
});
const responseExample2 =
microsoftGraphBatchWithTwoMessagesResponse[0].responses[1];
expect(
messages.filter(
(message) =>
message.externalId ===
'AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0AGnUPtcQC-Eiwmc39SmMpPgAAA8ZAfgAA',
)[0],
).toStrictEqual({
externalId:
'AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0AGnUPtcQC-Eiwmc39SmMpPgAAA8ZAfgAA',
subject: responseExample2.body.subject,
receivedAt: new Date(responseExample2.body.receivedDateTime),
text: responseExample2.body.body.content,
headerMessageId: responseExample2.body.internetMessageId,
messageThreadExternalId: responseExample2.body.conversationId,
direction: 'INCOMING',
participants: [
{
displayName: 'Microsoft',
handle: 'microsoft-noreply@microsoft.com',
role: 'from',
},
{
displayName: 'Walker',
handle: 'walker@felixacme.onmicrosoft.com',
role: 'to',
},
{
displayName: 'Antoine',
handle: 'antoine@gmail.com',
role: 'cc',
},
{
displayName: 'Cyril@acme2.com',
handle: 'cyril@acme2.com',
role: 'cc',
},
],
attachments: [],
});
});
it('Should set empty text for html responses', () => {
const batchResponses: MicrosoftGraphBatchResponse[] =
microsoftGraphBatchWithHtmlMessagesResponse;
const connectedAccount = {
id: 'connected-account-id',
provider: ConnectedAccountProvider.MICROSOFT,
refreshToken: 'refresh-token',
handle: 'John.l@outlook.fr',
handleAliases: '',
};
const messages = service.formatBatchResponsesAsMessages(
batchResponses,
connectedAccount,
);
const responseExample =
microsoftGraphBatchWithHtmlMessagesResponse[0].responses[0];
expect(messages[0]).toStrictEqual({
externalId: responseExample.body.id,
subject: responseExample.body.subject,
receivedAt: new Date(responseExample.body.receivedDateTime),
text: '',
headerMessageId: responseExample.body.internetMessageId,
messageThreadExternalId: responseExample.body.conversationId,
direction: 'OUTGOING',
participants: [
{
displayName: responseExample.body.sender.emailAddress.name,
handle:
responseExample.body.sender.emailAddress.address.toLowerCase(),
role: 'from',
},
],
attachments: [],
});
});
});

View File

@ -0,0 +1,141 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { computeMessageDirection } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-message-direction.util';
import { MicrosoftGraphBatchResponse } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.interface';
import { MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
import { formatAddressObjectAsParticipants } from 'src/modules/messaging/message-import-manager/utils/format-address-object-as-participants.util';
import { isDefined } from 'src/utils/is-defined';
import { MicrosoftFetchByBatchService } from './microsoft-fetch-by-batch.service';
import { MicrosoftHandleErrorService } from './microsoft-handle-error.service';
type ConnectedAccountType = Pick<
ConnectedAccountWorkspaceEntity,
'refreshToken' | 'id' | 'provider' | 'handle' | 'handleAliases'
>;
@Injectable()
export class MicrosoftGetMessagesService {
private readonly logger = new Logger(MicrosoftGetMessagesService.name);
constructor(
private readonly microsoftFetchByBatchService: MicrosoftFetchByBatchService,
private readonly microsoftHandleErrorService: MicrosoftHandleErrorService,
) {}
async getMessages(
messageIds: string[],
connectedAccount: ConnectedAccountType,
workspaceId: string,
): Promise<MessageWithParticipants[]> {
const startTime = Date.now();
try {
const { batchResponses } =
await this.microsoftFetchByBatchService.fetchAllByBatches(
messageIds,
connectedAccount,
);
const messages = this.formatBatchResponsesAsMessages(
batchResponses,
connectedAccount,
);
const endTime = Date.now();
this.logger.log(
`Messaging import for workspace ${workspaceId} and account ${
connectedAccount.id
} fetched ${messages.length} messages in ${endTime - startTime}ms`,
);
return messages;
} catch (error) {
this.microsoftHandleErrorService.handleMicrosoftMessageListFetchError(
error,
);
return [];
}
}
public formatBatchResponsesAsMessages(
batchResponses: MicrosoftGraphBatchResponse[],
connectedAccount: ConnectedAccountType,
): MessageWithParticipants[] {
return batchResponses.flatMap((batchResponse) => {
return this.formatBatchResponseAsMessages(
batchResponse,
connectedAccount,
);
});
}
private formatBatchResponseAsMessages(
batchResponse: MicrosoftGraphBatchResponse,
connectedAccount: ConnectedAccountType,
): MessageWithParticipants[] {
const parsedResponses = this.parseBatchResponse(batchResponse);
const messages = parsedResponses.map((response) => {
if ('error' in response) {
this.microsoftHandleErrorService.handleMicrosoftMessageListFetchError(
response.error,
);
}
const participants = [
...formatAddressObjectAsParticipants(
response?.from?.emailAddress,
'from',
),
...formatAddressObjectAsParticipants(
response?.toRecipients?.map((recipient) => recipient.emailAddress),
'to',
),
...formatAddressObjectAsParticipants(
response?.ccRecipients?.map((recipient) => recipient.emailAddress),
'cc',
),
...formatAddressObjectAsParticipants(
response?.bccRecipients?.map((recipient) => recipient.emailAddress),
'bcc',
),
];
return {
externalId: response.id,
subject: response.subject || '',
receivedAt: new Date(response.receivedDateTime),
text:
response.body?.contentType === 'text' ? response.body?.content : '',
headerMessageId: response.internetMessageId,
messageThreadExternalId: response.conversationId,
direction: computeMessageDirection(
response.from.emailAddress.address,
connectedAccount,
),
participants: participants,
attachments: [],
};
});
return messages.filter(isDefined);
}
private parseBatchResponse(batchResponse: MicrosoftGraphBatchResponse) {
if (!batchResponse?.responses) {
return [];
}
return batchResponse.responses.map((response: any) => {
if (response.status === 200) {
return response.body;
}
return { error: response.error };
});
}
}

View File

@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { GraphError } from '@microsoft/microsoft-graph-client';
import {
MessageImportDriverException,
MessageImportDriverExceptionCode,
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
@Injectable()
export class MicrosoftHandleErrorService {
public handleMicrosoftMessageListFetchError(error: GraphError): void {
if (error.statusCode === 401) {
throw new MessageImportDriverException(
'Unauthorized access to Microsoft Graph API',
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
}
if (error.statusCode === 403) {
throw new MessageImportDriverException(
'Forbidden access to Microsoft Graph API',
MessageImportDriverExceptionCode.INSUFFICIENT_PERMISSIONS,
);
}
throw new MessageImportDriverException(
`Microsoft Graph API error: ${error.message}`,
MessageImportDriverExceptionCode.UNKNOWN,
);
}
}

View File

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { GmailGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-messages.service';
import { MicrosoftGetMessagesService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-messages.service';
import {
MessageImportException,
MessageImportExceptionCode,
@ -14,6 +15,7 @@ export type GetMessagesResponse = MessageWithParticipants[];
export class MessagingGetMessagesService {
constructor(
private readonly gmailGetMessagesService: GmailGetMessagesService,
private readonly microsoftGetMessagesService: MicrosoftGetMessagesService,
) {}
public async getMessages(
@ -36,6 +38,12 @@ export class MessagingGetMessagesService {
connectedAccount,
workspaceId,
);
case 'microsoft':
return this.microsoftGetMessagesService.getMessages(
messageIds,
connectedAccount,
workspaceId,
);
default:
throw new MessageImportException(
`Provider ${connectedAccount.provider} is not supported`,

View File

@ -61,6 +61,10 @@ yarn command:prod cron:calendar:ongoing-stale
## For Outlook and Outlook Calendar (Microsoft 365)
<ArticleWarning>
Users must have a [Microsoft 365 Licence](https://admin.microsoft.com/Adminportal/Home) to be able to use the Calendar and Messaging API. They will not be able to sync their account on Twenty without one.
</ArticleWarning>
### Create a project in Microsoft Azure
You will need to create a project in [Microsoft Azure](https://portal.azure.com/#view/Microsoft_AAD_IAM/AppGalleryBladeV2) and get the credentials.

View File

@ -70,6 +70,10 @@ Most of the time, it's because the `worker` is not running in the background. Tr
npx nx worker twenty-server
```
#### Cannot connect my Microsoft 365 account
Most of the time, it's because your admin has not enabled the Microsoft 365 Licence for your account. Check [https://admin.microsoft.com/](https://admin.microsoft.com/Adminportal/Home).
#### While running `yarn` warnings appear in console
Warnings are informing about pulling additional dependencies which aren't explicitly stated in `package.json`, so as long as no breaking error appears, everything should work as expected.