Message folders optimization (#16479)
- Batch Gmail API calls using `googleapis-batcher` for folder processing - Add concurrency limit for Microsoft Graph folder processing - Skip IMAP folder sync when no new messages (checks UIDVALIDITY/MODSEQ) - Refactored `syncMessageFolders` to return folder state directly, avoiding extra DB round-trips - Refactored `processPendingFolderActions` to reuse state instead of querying DB again - Add unique index on message folders entity --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@@ -37,6 +37,7 @@
|
||||
"@graphql-tools/schema": "10.0.4",
|
||||
"@graphql-tools/utils": "9.2.1",
|
||||
"@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch",
|
||||
"@jrmdayn/googleapis-batcher": "^0.10.1",
|
||||
"@lingui/conf": "5.1.2",
|
||||
"@lingui/core": "^5.1.2",
|
||||
"@lingui/format-po": "5.1.2",
|
||||
|
||||
+17
-24
@@ -6,7 +6,6 @@ import { v4 } from 'uuid';
|
||||
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import {
|
||||
MessageChannelPendingGroupEmailsAction,
|
||||
MessageChannelSyncStage,
|
||||
@@ -48,25 +47,17 @@ export class CreateMessageChannelService {
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const connectedAccountRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'connectedAccount',
|
||||
);
|
||||
|
||||
const connectedAccount = await connectedAccountRepository.findOne({
|
||||
where: { id: connectedAccountId },
|
||||
});
|
||||
|
||||
const messageChannelRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'messageChannel',
|
||||
);
|
||||
|
||||
const newMessageChannel = await messageChannelRepository.save(
|
||||
const newMessageChannelId = v4();
|
||||
|
||||
await messageChannelRepository.insert(
|
||||
{
|
||||
id: v4(),
|
||||
id: newMessageChannelId,
|
||||
connectedAccountId,
|
||||
type: MessageChannelType.EMAIL,
|
||||
handle,
|
||||
@@ -77,22 +68,24 @@ export class CreateMessageChannelService {
|
||||
pendingGroupEmailsAction:
|
||||
MessageChannelPendingGroupEmailsAction.NONE,
|
||||
},
|
||||
{},
|
||||
manager,
|
||||
);
|
||||
|
||||
if (isDefined(connectedAccount)) {
|
||||
await this.syncMessageFoldersService.syncMessageFolders({
|
||||
workspaceId,
|
||||
messageChannel: {
|
||||
...newMessageChannel,
|
||||
connectedAccount,
|
||||
},
|
||||
manager,
|
||||
});
|
||||
const createdMessageChannel = await messageChannelRepository.findOne({
|
||||
where: { id: newMessageChannelId },
|
||||
relations: ['connectedAccount', 'messageFolders'],
|
||||
});
|
||||
|
||||
if (!isDefined(createdMessageChannel)) {
|
||||
throw new Error('Message channel not found');
|
||||
}
|
||||
|
||||
return newMessageChannel.id;
|
||||
await this.syncMessageFoldersService.syncMessageFolders({
|
||||
messageChannel: createdMessageChannel,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return newMessageChannelId;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+5
@@ -10,6 +10,7 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa
|
||||
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
|
||||
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
|
||||
import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
|
||||
import { WorkspaceIndex } from 'src/engine/twenty-orm/decorators/workspace-index.decorator';
|
||||
import { WorkspaceIsNotAuditLogged } from 'src/engine/twenty-orm/decorators/workspace-is-not-audit-logged.decorator';
|
||||
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator';
|
||||
import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
|
||||
@@ -39,6 +40,10 @@ registerEnumType(MessageFolderPendingSyncAction, {
|
||||
})
|
||||
@WorkspaceIsNotAuditLogged()
|
||||
@WorkspaceIsSystem()
|
||||
@WorkspaceIndex(['messageChannelId', 'externalId'], {
|
||||
isUnique: true,
|
||||
indexWhereClause: '"deletedAt" IS NULL',
|
||||
})
|
||||
export class MessageFolderWorkspaceEntity extends BaseWorkspaceEntity {
|
||||
@WorkspaceField({
|
||||
standardId: MESSAGE_FOLDER_STANDARD_FIELD_IDS.name,
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { google } from 'googleapis';
|
||||
|
||||
import {
|
||||
MessageFolder,
|
||||
DiscoveredMessageFolder,
|
||||
MessageFolderDriver,
|
||||
} from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
@@ -34,7 +34,7 @@ export class GmailGetAllFoldersService implements MessageFolderDriver {
|
||||
MessageChannelWorkspaceEntity,
|
||||
'messageFolderImportPolicy'
|
||||
>,
|
||||
): Promise<MessageFolder[]> {
|
||||
): Promise<DiscoveredMessageFolder[]> {
|
||||
try {
|
||||
const oAuth2Client =
|
||||
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
|
||||
@@ -60,7 +60,7 @@ export class GmailGetAllFoldersService implements MessageFolderDriver {
|
||||
|
||||
const labels = response.data.labels || [];
|
||||
|
||||
const folders: MessageFolder[] = [];
|
||||
const folders: DiscoveredMessageFolder[] = [];
|
||||
|
||||
const labelNameToIdMap = new Map<string, string>();
|
||||
|
||||
|
||||
+5
-5
@@ -4,7 +4,7 @@ import { ImapFlow, type ListResponse } from 'imapflow';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
MessageFolder,
|
||||
DiscoveredMessageFolder,
|
||||
MessageFolderDriver,
|
||||
} from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
@@ -34,7 +34,7 @@ export class ImapGetAllFoldersService implements MessageFolderDriver {
|
||||
MessageChannelWorkspaceEntity,
|
||||
'messageFolderImportPolicy'
|
||||
>,
|
||||
): Promise<MessageFolder[]> {
|
||||
): Promise<DiscoveredMessageFolder[]> {
|
||||
try {
|
||||
const client = await this.imapClientProvider.getClient(connectedAccount);
|
||||
|
||||
@@ -66,8 +66,8 @@ export class ImapGetAllFoldersService implements MessageFolderDriver {
|
||||
MessageChannelWorkspaceEntity,
|
||||
'messageFolderImportPolicy'
|
||||
>,
|
||||
): Promise<MessageFolder[]> {
|
||||
const folders: MessageFolder[] = [];
|
||||
): Promise<DiscoveredMessageFolder[]> {
|
||||
const folders: DiscoveredMessageFolder[] = [];
|
||||
const pathToExternalIdMap = new Map<string, string>();
|
||||
const sentFolder =
|
||||
await this.imapFindSentFolderService.findSentFolder(client);
|
||||
@@ -135,7 +135,7 @@ export class ImapGetAllFoldersService implements MessageFolderDriver {
|
||||
|
||||
private isValidMailbox(
|
||||
mailbox: ListResponse,
|
||||
existingFolders: MessageFolder[],
|
||||
existingFolders: DiscoveredMessageFolder[],
|
||||
): boolean {
|
||||
if (mailbox.flags?.has('\\Noselect')) {
|
||||
return false;
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
MessageFolder,
|
||||
DiscoveredMessageFolder,
|
||||
MessageFolderDriver,
|
||||
} from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
@@ -44,7 +44,7 @@ export class MicrosoftGetAllFoldersService implements MessageFolderDriver {
|
||||
MessageChannelWorkspaceEntity,
|
||||
'messageFolderImportPolicy'
|
||||
>,
|
||||
): Promise<MessageFolder[]> {
|
||||
): Promise<DiscoveredMessageFolder[]> {
|
||||
try {
|
||||
const microsoftClient =
|
||||
await this.oAuth2ClientManagerService.getMicrosoftOAuth2Client(
|
||||
@@ -67,7 +67,7 @@ export class MicrosoftGetAllFoldersService implements MessageFolderDriver {
|
||||
|
||||
const folders = (response.value as MicrosoftGraphFolder[]) || [];
|
||||
const rootFolderId = this.getRootFolderId(folders);
|
||||
const folderInfos: MessageFolder[] = [];
|
||||
const folderInfos: DiscoveredMessageFolder[] = [];
|
||||
|
||||
for (const folder of folders) {
|
||||
if (!folder.displayName) {
|
||||
|
||||
+16
-4
@@ -2,12 +2,24 @@ import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-acco
|
||||
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
|
||||
export type MessageFolder = Pick<
|
||||
export type DiscoveredMessageFolder = Pick<
|
||||
MessageFolderWorkspaceEntity,
|
||||
'name' | 'isSynced' | 'isSentFolder' | 'externalId' | 'parentFolderId'
|
||||
>;
|
||||
|
||||
export interface MessageFolderDriver {
|
||||
export type MessageFolder = Pick<
|
||||
MessageFolderWorkspaceEntity,
|
||||
| 'name'
|
||||
| 'isSynced'
|
||||
| 'isSentFolder'
|
||||
| 'externalId'
|
||||
| 'parentFolderId'
|
||||
| 'id'
|
||||
| 'syncCursor'
|
||||
| 'pendingSyncAction'
|
||||
>;
|
||||
|
||||
export type MessageFolderDriver = {
|
||||
getAllMessageFolders(
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountWorkspaceEntity,
|
||||
@@ -22,5 +34,5 @@ export interface MessageFolderDriver {
|
||||
MessageChannelWorkspaceEntity,
|
||||
'messageFolderImportPolicy'
|
||||
>,
|
||||
): Promise<MessageFolder[]>;
|
||||
}
|
||||
): Promise<DiscoveredMessageFolder[]>;
|
||||
};
|
||||
|
||||
+543
@@ -0,0 +1,543 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
|
||||
import { type DiscoveredMessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import {
|
||||
MessageChannelContactAutoCreationPolicy,
|
||||
MessageChannelType,
|
||||
MessageChannelVisibility,
|
||||
MessageFolderImportPolicy,
|
||||
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
import {
|
||||
MessageFolderPendingSyncAction,
|
||||
type MessageFolderWorkspaceEntity,
|
||||
} from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import { GmailGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/gmail/services/gmail-get-all-folders.service';
|
||||
import { ImapGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/imap/services/imap-get-all-folders.service';
|
||||
import { MicrosoftGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/microsoft/services/microsoft-get-all-folders.service';
|
||||
import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service';
|
||||
|
||||
type SyncedMessageFolder = Pick<
|
||||
MessageFolderWorkspaceEntity,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'isSynced'
|
||||
| 'isSentFolder'
|
||||
| 'externalId'
|
||||
| 'syncCursor'
|
||||
| 'parentFolderId'
|
||||
| 'pendingSyncAction'
|
||||
>;
|
||||
|
||||
const createMockMessageChannel = (
|
||||
overrides: {
|
||||
provider?: ConnectedAccountProvider;
|
||||
messageFolders?: SyncedMessageFolder[];
|
||||
} = {},
|
||||
) => ({
|
||||
id: 'channel-123',
|
||||
handle: 'test@gmail.com',
|
||||
type: MessageChannelType.EMAIL,
|
||||
messageFolderImportPolicy: MessageFolderImportPolicy.ALL_FOLDERS,
|
||||
connectedAccount: {
|
||||
id: 'account-456',
|
||||
handle: 'test@gmail.com',
|
||||
provider: overrides.provider ?? ConnectedAccountProvider.GOOGLE,
|
||||
accessToken: 'mock-access-token',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
connectionParameters: {},
|
||||
},
|
||||
messageFolders: overrides.messageFolders ?? [],
|
||||
visibility: MessageChannelVisibility.SHARE_EVERYTHING,
|
||||
isContactAutoCreationEnabled: false,
|
||||
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy.NONE,
|
||||
excludeNonProfessionalEmails: false,
|
||||
excludeGroupEmails: false,
|
||||
});
|
||||
|
||||
const createMockDiscoveredFolder = (
|
||||
overrides: Partial<DiscoveredMessageFolder> = {},
|
||||
): DiscoveredMessageFolder => ({
|
||||
externalId: `external-${Math.random().toString(36).substring(7)}`,
|
||||
name: 'Test Folder',
|
||||
isSynced: false,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockExistingFolder = (
|
||||
overrides: Partial<SyncedMessageFolder> = {},
|
||||
): SyncedMessageFolder => ({
|
||||
id: `folder-${Math.random().toString(36).substring(7)}`,
|
||||
externalId: `external-${Math.random().toString(36).substring(7)}`,
|
||||
name: 'Existing Folder',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
syncCursor: null,
|
||||
parentFolderId: null,
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('SyncMessageFoldersService', () => {
|
||||
let service: SyncMessageFoldersService;
|
||||
let gmailGetAllFoldersService: jest.Mocked<GmailGetAllFoldersService>;
|
||||
|
||||
let mockRepository: {
|
||||
delete: jest.Mock;
|
||||
update: jest.Mock;
|
||||
updateMany: jest.Mock;
|
||||
save: jest.Mock;
|
||||
};
|
||||
let mockTransactionManager: object;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockRepository = {
|
||||
delete: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
save: jest.fn().mockImplementation((folders) =>
|
||||
folders.map((folder: Partial<MessageFolderWorkspaceEntity>) => ({
|
||||
...folder,
|
||||
id: `new-folder-${Math.random().toString(36).substring(7)}`,
|
||||
isSynced: false,
|
||||
syncCursor: null,
|
||||
})),
|
||||
),
|
||||
};
|
||||
|
||||
mockTransactionManager = {};
|
||||
|
||||
const mockDataSource = {
|
||||
transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((callback) => callback(mockTransactionManager)),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SyncMessageFoldersService,
|
||||
{
|
||||
provide: GlobalWorkspaceOrmManager,
|
||||
useValue: {
|
||||
executeInWorkspaceContext: jest
|
||||
.fn()
|
||||
.mockImplementation((_, callback) => callback()),
|
||||
getRepository: jest.fn().mockResolvedValue(mockRepository),
|
||||
getDataSourceForWorkspace: jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockDataSource),
|
||||
getGlobalWorkspaceDataSource: jest
|
||||
.fn()
|
||||
.mockResolvedValue(mockDataSource),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: GmailGetAllFoldersService,
|
||||
useValue: {
|
||||
getAllMessageFolders: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MicrosoftGetAllFoldersService,
|
||||
useValue: {
|
||||
getAllMessageFolders: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ImapGetAllFoldersService,
|
||||
useValue: {
|
||||
getAllMessageFolders: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SyncMessageFoldersService>(SyncMessageFoldersService);
|
||||
gmailGetAllFoldersService = module.get(GmailGetAllFoldersService);
|
||||
});
|
||||
|
||||
describe('syncMessageFolders', () => {
|
||||
const workspaceId = 'workspace-789';
|
||||
|
||||
describe('folder creation scenarios', () => {
|
||||
it('should create new folders when none exist locally', async () => {
|
||||
const discoveredFolders = [
|
||||
createMockDiscoveredFolder({
|
||||
externalId: 'inbox-ext',
|
||||
name: 'INBOX',
|
||||
}),
|
||||
createMockDiscoveredFolder({
|
||||
externalId: 'sent-ext',
|
||||
name: 'Sent',
|
||||
isSentFolder: true,
|
||||
}),
|
||||
];
|
||||
const messageChannel = createMockMessageChannel({
|
||||
messageFolders: [],
|
||||
});
|
||||
|
||||
gmailGetAllFoldersService.getAllMessageFolders.mockResolvedValue(
|
||||
discoveredFolders,
|
||||
);
|
||||
|
||||
const result = await service.syncMessageFolders({
|
||||
messageChannel,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(mockRepository.save).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'INBOX',
|
||||
externalId: 'inbox-ext',
|
||||
messageChannelId: 'channel-123',
|
||||
isSentFolder: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'Sent',
|
||||
externalId: 'sent-ext',
|
||||
messageChannelId: 'channel-123',
|
||||
isSentFolder: true,
|
||||
}),
|
||||
]),
|
||||
{},
|
||||
mockTransactionManager,
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle nested folder creation with parent references', async () => {
|
||||
const discoveredFolders = [
|
||||
createMockDiscoveredFolder({
|
||||
externalId: 'parent-ext',
|
||||
name: 'Work',
|
||||
parentFolderId: null,
|
||||
}),
|
||||
createMockDiscoveredFolder({
|
||||
externalId: 'child-ext',
|
||||
name: 'Projects',
|
||||
parentFolderId: 'parent-folder-id',
|
||||
}),
|
||||
];
|
||||
const messageChannel = createMockMessageChannel({
|
||||
messageFolders: [],
|
||||
});
|
||||
|
||||
gmailGetAllFoldersService.getAllMessageFolders.mockResolvedValue(
|
||||
discoveredFolders,
|
||||
);
|
||||
|
||||
await service.syncMessageFolders({
|
||||
messageChannel,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(mockRepository.save).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'Projects',
|
||||
parentFolderId: 'parent-folder-id',
|
||||
}),
|
||||
]),
|
||||
{},
|
||||
mockTransactionManager,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder update scenarios', () => {
|
||||
it('should update folder when name changes', async () => {
|
||||
const existingFolder = createMockExistingFolder({
|
||||
id: 'folder-1',
|
||||
externalId: 'inbox-ext',
|
||||
name: 'INBOX',
|
||||
});
|
||||
const discoveredFolders = [
|
||||
createMockDiscoveredFolder({
|
||||
externalId: 'inbox-ext',
|
||||
name: 'Primary Inbox',
|
||||
}),
|
||||
];
|
||||
const messageChannel = createMockMessageChannel({
|
||||
messageFolders: [existingFolder],
|
||||
});
|
||||
|
||||
gmailGetAllFoldersService.getAllMessageFolders.mockResolvedValue(
|
||||
discoveredFolders,
|
||||
);
|
||||
|
||||
const result = await service.syncMessageFolders({
|
||||
messageChannel,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(mockRepository.updateMany).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
criteria: 'folder-1',
|
||||
partialEntity: expect.objectContaining({ name: 'Primary Inbox' }),
|
||||
}),
|
||||
]),
|
||||
mockTransactionManager,
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'folder-1',
|
||||
name: 'Primary Inbox',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update folder when parent folder changes', async () => {
|
||||
const existingFolder = createMockExistingFolder({
|
||||
id: 'folder-1',
|
||||
externalId: 'child-ext',
|
||||
name: 'Projects',
|
||||
parentFolderId: 'old-parent-id',
|
||||
});
|
||||
const discoveredFolders = [
|
||||
createMockDiscoveredFolder({
|
||||
externalId: 'child-ext',
|
||||
name: 'Projects',
|
||||
parentFolderId: 'new-parent-id',
|
||||
}),
|
||||
];
|
||||
const messageChannel = createMockMessageChannel({
|
||||
messageFolders: [existingFolder],
|
||||
});
|
||||
|
||||
gmailGetAllFoldersService.getAllMessageFolders.mockResolvedValue(
|
||||
discoveredFolders,
|
||||
);
|
||||
|
||||
await service.syncMessageFolders({
|
||||
messageChannel,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(mockRepository.updateMany).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
criteria: 'folder-1',
|
||||
partialEntity: expect.objectContaining({
|
||||
parentFolderId: 'new-parent-id',
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
mockTransactionManager,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not update folder when nothing has changed', async () => {
|
||||
const existingFolder = createMockExistingFolder({
|
||||
id: 'folder-1',
|
||||
externalId: 'inbox-ext',
|
||||
name: 'INBOX',
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
});
|
||||
const discoveredFolders = [
|
||||
createMockDiscoveredFolder({
|
||||
externalId: 'inbox-ext',
|
||||
name: 'INBOX',
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
}),
|
||||
];
|
||||
const messageChannel = createMockMessageChannel({
|
||||
messageFolders: [existingFolder],
|
||||
});
|
||||
|
||||
gmailGetAllFoldersService.getAllMessageFolders.mockResolvedValue(
|
||||
discoveredFolders,
|
||||
);
|
||||
|
||||
await service.syncMessageFolders({
|
||||
messageChannel,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(mockRepository.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('folder deletion scenarios', () => {
|
||||
it('should delete folders that no longer exist remotely', async () => {
|
||||
const existingFolders = [
|
||||
createMockExistingFolder({
|
||||
id: 'folder-1',
|
||||
externalId: 'inbox-ext',
|
||||
name: 'INBOX',
|
||||
}),
|
||||
createMockExistingFolder({
|
||||
id: 'folder-2',
|
||||
externalId: 'deleted-ext',
|
||||
name: 'Old Folder',
|
||||
}),
|
||||
];
|
||||
const discoveredFolders = [
|
||||
createMockDiscoveredFolder({
|
||||
externalId: 'inbox-ext',
|
||||
name: 'INBOX',
|
||||
}),
|
||||
];
|
||||
const messageChannel = createMockMessageChannel({
|
||||
messageFolders: existingFolders,
|
||||
});
|
||||
|
||||
gmailGetAllFoldersService.getAllMessageFolders.mockResolvedValue(
|
||||
discoveredFolders,
|
||||
);
|
||||
|
||||
const result = await service.syncMessageFolders({
|
||||
messageChannel: messageChannel,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(mockRepository.updateMany).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
criteria: 'folder-2',
|
||||
partialEntity: expect.objectContaining({
|
||||
pendingSyncAction: 'FOLDER_DELETION',
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
mockTransactionManager,
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'folder-2',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.FOLDER_DELETION,
|
||||
}),
|
||||
);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex sync scenarios', () => {
|
||||
it('should handle simultaneous create, update, and delete operations', async () => {
|
||||
const existingFolders = [
|
||||
createMockExistingFolder({
|
||||
id: 'folder-to-update',
|
||||
externalId: 'update-ext',
|
||||
name: 'Old Name',
|
||||
}),
|
||||
createMockExistingFolder({
|
||||
id: 'folder-to-delete',
|
||||
externalId: 'delete-ext',
|
||||
name: 'To Delete',
|
||||
}),
|
||||
createMockExistingFolder({
|
||||
id: 'folder-unchanged',
|
||||
externalId: 'unchanged-ext',
|
||||
name: 'Unchanged',
|
||||
}),
|
||||
];
|
||||
const discoveredFolders = [
|
||||
createMockDiscoveredFolder({
|
||||
externalId: 'update-ext',
|
||||
name: 'New Name',
|
||||
}),
|
||||
createMockDiscoveredFolder({
|
||||
externalId: 'unchanged-ext',
|
||||
name: 'Unchanged',
|
||||
}),
|
||||
createMockDiscoveredFolder({
|
||||
externalId: 'new-ext',
|
||||
name: 'New Folder',
|
||||
}),
|
||||
];
|
||||
const messageChannel = createMockMessageChannel({
|
||||
messageFolders: existingFolders,
|
||||
});
|
||||
|
||||
gmailGetAllFoldersService.getAllMessageFolders.mockResolvedValue(
|
||||
discoveredFolders,
|
||||
);
|
||||
|
||||
const result = await service.syncMessageFolders({
|
||||
messageChannel,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(mockRepository.updateMany).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
criteria: 'folder-to-delete',
|
||||
partialEntity: expect.objectContaining({
|
||||
pendingSyncAction: 'FOLDER_DELETION',
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
mockTransactionManager,
|
||||
);
|
||||
expect(mockRepository.updateMany).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
criteria: 'folder-to-update',
|
||||
partialEntity: expect.objectContaining({ name: 'New Name' }),
|
||||
}),
|
||||
]),
|
||||
mockTransactionManager,
|
||||
);
|
||||
expect(mockRepository.save).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ externalId: 'new-ext' }),
|
||||
]),
|
||||
{},
|
||||
mockTransactionManager,
|
||||
);
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'folder-to-delete',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.FOLDER_DELETION,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve syncCursor and isSynced for unchanged folders', async () => {
|
||||
const existingFolder = createMockExistingFolder({
|
||||
id: 'folder-1',
|
||||
externalId: 'inbox-ext',
|
||||
name: 'INBOX',
|
||||
isSynced: true,
|
||||
syncCursor: 'cursor-abc123',
|
||||
});
|
||||
const discoveredFolders = [
|
||||
createMockDiscoveredFolder({
|
||||
externalId: 'inbox-ext',
|
||||
name: 'INBOX',
|
||||
}),
|
||||
];
|
||||
const messageChannel = createMockMessageChannel({
|
||||
messageFolders: [existingFolder],
|
||||
});
|
||||
|
||||
gmailGetAllFoldersService.getAllMessageFolders.mockResolvedValue(
|
||||
discoveredFolders,
|
||||
);
|
||||
|
||||
const result = await service.syncMessageFolders({
|
||||
messageChannel,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'folder-1',
|
||||
isSynced: true,
|
||||
syncCursor: 'cursor-abc123',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+123
-207
@@ -1,54 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import deepEqual from 'deep-equal';
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { MessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
import {
|
||||
DiscoveredMessageFolder,
|
||||
MessageFolder,
|
||||
} from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
|
||||
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
import {
|
||||
MessageFolderPendingSyncAction,
|
||||
type MessageFolderWorkspaceEntity,
|
||||
MessageFolderWorkspaceEntity,
|
||||
} from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import { GmailGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/gmail/services/gmail-get-all-folders.service';
|
||||
import { ImapGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/imap/services/imap-get-all-folders.service';
|
||||
import { MicrosoftGetAllFoldersService } from 'src/modules/messaging/message-folder-manager/drivers/microsoft/services/microsoft-get-all-folders.service';
|
||||
import { MessageFolderName } from 'src/modules/messaging/message-import-manager/drivers/microsoft/types/folders';
|
||||
|
||||
type SyncMessageFoldersInput = {
|
||||
workspaceId: string;
|
||||
messageChannel: Pick<
|
||||
MessageChannelWorkspaceEntity,
|
||||
'messageFolderImportPolicy' | 'connectedAccount' | 'id'
|
||||
>;
|
||||
manager: WorkspaceEntityManager;
|
||||
};
|
||||
|
||||
type MessageFolderToInsert = Pick<
|
||||
MessageFolderWorkspaceEntity,
|
||||
| 'id'
|
||||
| 'messageChannelId'
|
||||
| 'name'
|
||||
| 'syncCursor'
|
||||
| 'isSynced'
|
||||
| 'isSentFolder'
|
||||
| 'externalId'
|
||||
| 'parentFolderId'
|
||||
>;
|
||||
|
||||
type MessageFolderToUpdate = Partial<
|
||||
Pick<
|
||||
MessageFolderWorkspaceEntity,
|
||||
'name' | 'externalId' | 'isSentFolder' | 'parentFolderId'
|
||||
>
|
||||
>;
|
||||
import { computeFolderIdsToDelete } from 'src/modules/messaging/message-folder-manager/utils/compute-folder-ids-to-delete.util';
|
||||
import { computeFoldersToCreate } from 'src/modules/messaging/message-folder-manager/utils/compute-folders-to-create.util';
|
||||
import { computeFoldersToUpdate } from 'src/modules/messaging/message-folder-manager/utils/compute-folders-to-update.util';
|
||||
import { computeUpdatedFolders } from 'src/modules/messaging/message-folder-manager/utils/compute-updated-folders.util';
|
||||
|
||||
@Injectable()
|
||||
export class SyncMessageFoldersService {
|
||||
@@ -59,160 +33,71 @@ export class SyncMessageFoldersService {
|
||||
private readonly imapGetAllFoldersService: ImapGetAllFoldersService,
|
||||
) {}
|
||||
|
||||
async syncMessageFolders(input: SyncMessageFoldersInput): Promise<void> {
|
||||
const { workspaceId, messageChannel, manager } = input;
|
||||
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const folders = await this.discoverAllFolders(
|
||||
messageChannel.connectedAccount,
|
||||
messageChannel,
|
||||
);
|
||||
|
||||
await this.upsertDiscoveredFolders({
|
||||
workspaceId,
|
||||
messageChannelId: messageChannel.id,
|
||||
folders,
|
||||
manager,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async upsertDiscoveredFolders({
|
||||
async syncMessageFolders({
|
||||
messageChannel,
|
||||
workspaceId,
|
||||
messageChannelId,
|
||||
folders,
|
||||
manager,
|
||||
}: {
|
||||
messageChannel: Pick<
|
||||
MessageChannelWorkspaceEntity,
|
||||
'id' | 'messageFolderImportPolicy'
|
||||
> & {
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountWorkspaceEntity,
|
||||
| 'provider'
|
||||
| 'accessToken'
|
||||
| 'refreshToken'
|
||||
| 'id'
|
||||
| 'handle'
|
||||
| 'connectionParameters'
|
||||
>;
|
||||
messageFolders: MessageFolder[];
|
||||
};
|
||||
workspaceId: string;
|
||||
messageChannelId: string;
|
||||
folders: MessageFolder[];
|
||||
manager: WorkspaceEntityManager;
|
||||
}): Promise<void> {
|
||||
const messageFolderRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<MessageFolderWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'messageFolder',
|
||||
);
|
||||
|
||||
const existingFolderMap = await this.buildExistingFolderMap({
|
||||
messageChannelId,
|
||||
messageFolderRepository,
|
||||
});
|
||||
|
||||
const inserts: MessageFolderToInsert[] = [];
|
||||
const updates: [string, MessageFolderToUpdate][] = [];
|
||||
const foldersToMarkForDeletion: string[] = [];
|
||||
|
||||
const discoveredExternalIds = new Set(
|
||||
folders
|
||||
.filter((folder) => folder.externalId)
|
||||
.map((folder) => folder.externalId!),
|
||||
}): Promise<MessageFolder[]> {
|
||||
const discoveredFolders = await this.discoverAllFolders(
|
||||
messageChannel.connectedAccount,
|
||||
messageChannel,
|
||||
);
|
||||
|
||||
for (const existingFolder of existingFolderMap.values()) {
|
||||
if (
|
||||
existingFolder.externalId &&
|
||||
!discoveredExternalIds.has(existingFolder.externalId)
|
||||
) {
|
||||
foldersToMarkForDeletion.push(existingFolder.id);
|
||||
}
|
||||
}
|
||||
const { messageFolders: existingFolders, id: messageChannelId } =
|
||||
messageChannel;
|
||||
|
||||
for (const folder of folders) {
|
||||
const existingFolder = this.findExistingFolderInMap(
|
||||
existingFolderMap,
|
||||
folder,
|
||||
);
|
||||
|
||||
if (existingFolder) {
|
||||
const folderSyncData = {
|
||||
name: folder.name,
|
||||
externalId: folder.externalId,
|
||||
isSentFolder: folder.isSentFolder,
|
||||
parentFolderId: isNonEmptyString(folder.parentFolderId)
|
||||
? folder.parentFolderId
|
||||
: null,
|
||||
};
|
||||
|
||||
const existingFolderData = {
|
||||
name: existingFolder.name,
|
||||
externalId: existingFolder.externalId,
|
||||
isSentFolder: existingFolder.isSentFolder,
|
||||
parentFolderId: isNonEmptyString(existingFolder.parentFolderId)
|
||||
? existingFolder.parentFolderId
|
||||
: null,
|
||||
};
|
||||
|
||||
if (!deepEqual(folderSyncData, existingFolderData)) {
|
||||
updates.push([existingFolder.id, folderSyncData]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
inserts.push({
|
||||
id: v4(),
|
||||
messageChannelId,
|
||||
name: folder.name,
|
||||
syncCursor: '',
|
||||
isSynced: folder.isSynced,
|
||||
isSentFolder: folder.isSentFolder,
|
||||
externalId: folder.externalId,
|
||||
parentFolderId: folder.parentFolderId,
|
||||
});
|
||||
}
|
||||
|
||||
if (inserts.length > 0) {
|
||||
await messageFolderRepository.insert(inserts, manager);
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
await messageFolderRepository.updateMany(
|
||||
updates.map(([id, data]) => ({
|
||||
criteria: id,
|
||||
partialEntity: data,
|
||||
})),
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
if (foldersToMarkForDeletion.length > 0) {
|
||||
await messageFolderRepository.updateMany(
|
||||
foldersToMarkForDeletion.map((id) => ({
|
||||
criteria: id,
|
||||
partialEntity: {
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.FOLDER_DELETION,
|
||||
},
|
||||
})),
|
||||
manager,
|
||||
);
|
||||
}
|
||||
return this.syncFolderChanges(
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
messageChannelId,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
async discoverAllFolders(
|
||||
connectedAccount: MessageChannelWorkspaceEntity['connectedAccount'],
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountWorkspaceEntity,
|
||||
| 'accessToken'
|
||||
| 'refreshToken'
|
||||
| 'id'
|
||||
| 'handle'
|
||||
| 'provider'
|
||||
| 'connectionParameters'
|
||||
>,
|
||||
messageChannel: Pick<
|
||||
MessageChannelWorkspaceEntity,
|
||||
'messageFolderImportPolicy'
|
||||
>,
|
||||
): Promise<MessageFolder[]> {
|
||||
): Promise<DiscoveredMessageFolder[]> {
|
||||
switch (connectedAccount.provider) {
|
||||
case ConnectedAccountProvider.GOOGLE:
|
||||
return await this.gmailGetAllFoldersService.getAllMessageFolders(
|
||||
return this.gmailGetAllFoldersService.getAllMessageFolders(
|
||||
connectedAccount,
|
||||
messageChannel,
|
||||
);
|
||||
case ConnectedAccountProvider.MICROSOFT:
|
||||
return await this.microsoftGetAllFoldersService.getAllMessageFolders(
|
||||
return this.microsoftGetAllFoldersService.getAllMessageFolders(
|
||||
connectedAccount,
|
||||
messageChannel,
|
||||
);
|
||||
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
|
||||
return await this.imapGetAllFoldersService.getAllMessageFolders(
|
||||
return this.imapGetAllFoldersService.getAllMessageFolders(
|
||||
connectedAccount,
|
||||
messageChannel,
|
||||
);
|
||||
@@ -223,55 +108,86 @@ export class SyncMessageFoldersService {
|
||||
}
|
||||
}
|
||||
|
||||
private async buildExistingFolderMap({
|
||||
messageChannelId,
|
||||
messageFolderRepository,
|
||||
}: {
|
||||
messageChannelId: string;
|
||||
messageFolderRepository: WorkspaceRepository<MessageFolderWorkspaceEntity>;
|
||||
}): Promise<Map<string, MessageFolderWorkspaceEntity>> {
|
||||
const existingFolders = await messageFolderRepository.find({
|
||||
where: { messageChannelId },
|
||||
private async syncFolderChanges(
|
||||
discoveredFolders: DiscoveredMessageFolder[],
|
||||
existingFolders: MessageFolder[],
|
||||
messageChannelId: string,
|
||||
workspaceId: string,
|
||||
): Promise<MessageFolder[]> {
|
||||
const foldersToCreate = computeFoldersToCreate({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
messageChannelId,
|
||||
});
|
||||
|
||||
const existingFolderMap = new Map<string, MessageFolderWorkspaceEntity>();
|
||||
const foldersToUpdate = computeFoldersToUpdate({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
});
|
||||
|
||||
for (const existingFolder of existingFolders) {
|
||||
if (isDefined(existingFolder.externalId)) {
|
||||
existingFolderMap.set(existingFolder.externalId, existingFolder);
|
||||
}
|
||||
existingFolderMap.set(existingFolder.name ?? '', existingFolder);
|
||||
}
|
||||
const folderIdsToDelete = computeFolderIdsToDelete({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
});
|
||||
|
||||
return existingFolderMap;
|
||||
}
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
|
||||
private findExistingFolderInMap(
|
||||
existingFolderMap: Map<string, MessageFolderWorkspaceEntity>,
|
||||
folder: MessageFolder,
|
||||
): MessageFolderWorkspaceEntity | undefined {
|
||||
if (isDefined(folder.externalId)) {
|
||||
const existingFolder = existingFolderMap.get(folder.externalId);
|
||||
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
|
||||
authContext,
|
||||
async () => {
|
||||
const messageFolderRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<MessageFolderWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'messageFolder',
|
||||
);
|
||||
|
||||
if (existingFolder) {
|
||||
return existingFolder;
|
||||
}
|
||||
}
|
||||
const workspaceDataSource =
|
||||
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
|
||||
|
||||
const legacyFolderName = this.getLegacyFolderName(folder);
|
||||
return workspaceDataSource.transaction(
|
||||
async (transactionManager: WorkspaceEntityManager) => {
|
||||
if (folderIdsToDelete.length > 0) {
|
||||
await messageFolderRepository.updateMany(
|
||||
folderIdsToDelete.map((id) => ({
|
||||
criteria: id,
|
||||
partialEntity: {
|
||||
pendingSyncAction:
|
||||
MessageFolderPendingSyncAction.FOLDER_DELETION,
|
||||
},
|
||||
})),
|
||||
transactionManager,
|
||||
);
|
||||
}
|
||||
|
||||
return existingFolderMap.get(legacyFolderName);
|
||||
}
|
||||
if (foldersToUpdate.size > 0) {
|
||||
await messageFolderRepository.updateMany(
|
||||
Array.from(foldersToUpdate.entries()).map(([id, data]) => ({
|
||||
criteria: id,
|
||||
partialEntity: data,
|
||||
})),
|
||||
transactionManager,
|
||||
);
|
||||
}
|
||||
|
||||
private getLegacyFolderName(folder: MessageFolder): string {
|
||||
if (folder.isSynced && !folder.isSentFolder) {
|
||||
return MessageFolderName.INBOX;
|
||||
}
|
||||
const createdFolders =
|
||||
foldersToCreate.length > 0
|
||||
? await messageFolderRepository.save(
|
||||
foldersToCreate,
|
||||
{},
|
||||
transactionManager,
|
||||
)
|
||||
: [];
|
||||
|
||||
if (folder.isSynced && folder.isSentFolder) {
|
||||
return MessageFolderName.SENT_ITEMS;
|
||||
}
|
||||
const updatedExistingFolders = computeUpdatedFolders({
|
||||
existingFolders,
|
||||
foldersToUpdate,
|
||||
folderIdsToDelete,
|
||||
});
|
||||
|
||||
return folder.name ?? '';
|
||||
return [...updatedExistingFolders, ...createdFolders];
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { MessageFolderPendingSyncAction } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import { computeFolderIdsToDelete } from 'src/modules/messaging/message-folder-manager/utils/compute-folder-ids-to-delete.util';
|
||||
|
||||
describe('computeFolderIdsToDelete', () => {
|
||||
it('should mark folders deleted from provider for deletion', () => {
|
||||
const discoveredFolders = [
|
||||
{
|
||||
name: 'Inbox',
|
||||
externalId: 'INBOX',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
},
|
||||
];
|
||||
|
||||
const existingFolders = [
|
||||
{
|
||||
id: 'inbox-id',
|
||||
name: 'Inbox',
|
||||
externalId: 'INBOX',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
syncCursor: 'cursor',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
{
|
||||
id: 'deleted-label-id',
|
||||
name: 'Old Label',
|
||||
externalId: 'deleted-label',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
syncCursor: 'cursor',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
];
|
||||
|
||||
const result = computeFolderIdsToDelete({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['deleted-label-id']);
|
||||
});
|
||||
|
||||
it('should return empty when all local folders still exist in provider', () => {
|
||||
const discoveredFolders = [
|
||||
{
|
||||
name: 'Inbox',
|
||||
externalId: 'INBOX',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
},
|
||||
];
|
||||
|
||||
const existingFolders = [
|
||||
{
|
||||
id: 'inbox-id',
|
||||
name: 'Inbox',
|
||||
externalId: 'INBOX',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
syncCursor: 'cursor',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
];
|
||||
|
||||
const result = computeFolderIdsToDelete({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { MessageFolderPendingSyncAction } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import { computeFoldersToCreate } from 'src/modules/messaging/message-folder-manager/utils/compute-folders-to-create.util';
|
||||
|
||||
describe('computeFoldersToCreate', () => {
|
||||
const messageChannelId = 'channel-123';
|
||||
|
||||
it('should create folders that exist in provider but not locally', () => {
|
||||
const discoveredFolders = [
|
||||
{
|
||||
name: 'Inbox',
|
||||
externalId: 'INBOX',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
},
|
||||
{
|
||||
name: 'Sent',
|
||||
externalId: 'SENT',
|
||||
isSynced: true,
|
||||
isSentFolder: true,
|
||||
parentFolderId: null,
|
||||
},
|
||||
];
|
||||
|
||||
const existingFolders = [
|
||||
{
|
||||
id: 'existing-id',
|
||||
name: 'Inbox',
|
||||
externalId: 'INBOX',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
syncCursor: 'cursor',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
];
|
||||
|
||||
const result = computeFoldersToCreate({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
messageChannelId,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].externalId).toBe('SENT');
|
||||
expect(result[0].messageChannelId).toBe(messageChannelId);
|
||||
expect(result[0].syncCursor).toBeNull();
|
||||
});
|
||||
|
||||
it('should normalize empty parentFolderId to null', () => {
|
||||
const discoveredFolders = [
|
||||
{
|
||||
name: 'Subfolder',
|
||||
externalId: 'sub-1',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: '',
|
||||
},
|
||||
];
|
||||
|
||||
const result = computeFoldersToCreate({
|
||||
discoveredFolders,
|
||||
existingFolders: [],
|
||||
messageChannelId,
|
||||
});
|
||||
|
||||
expect(result[0].parentFolderId).toBeNull();
|
||||
});
|
||||
});
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { MessageFolderPendingSyncAction } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import { computeFoldersToUpdate } from 'src/modules/messaging/message-folder-manager/utils/compute-folders-to-update.util';
|
||||
|
||||
describe('computeFoldersToUpdate', () => {
|
||||
it('should detect folder rename from provider', () => {
|
||||
const discoveredFolders = [
|
||||
{
|
||||
name: 'Work Emails',
|
||||
externalId: 'label-123',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
},
|
||||
];
|
||||
|
||||
const existingFolders = [
|
||||
{
|
||||
id: 'folder-id',
|
||||
name: 'Old Label Name',
|
||||
externalId: 'label-123',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
syncCursor: 'cursor',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
];
|
||||
|
||||
const result = computeFoldersToUpdate({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
});
|
||||
|
||||
expect(result.get('folder-id')?.name).toBe('Work Emails');
|
||||
});
|
||||
|
||||
it('should detect folder moved to different parent', () => {
|
||||
const discoveredFolders = [
|
||||
{
|
||||
name: 'Subfolder',
|
||||
externalId: 'sub-1',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: 'new-parent-id',
|
||||
},
|
||||
];
|
||||
|
||||
const existingFolders = [
|
||||
{
|
||||
id: 'folder-id',
|
||||
name: 'Subfolder',
|
||||
externalId: 'sub-1',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: 'old-parent-id',
|
||||
syncCursor: 'cursor',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
];
|
||||
|
||||
const result = computeFoldersToUpdate({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
});
|
||||
|
||||
expect(result.get('folder-id')?.parentFolderId).toBe('new-parent-id');
|
||||
});
|
||||
|
||||
it('should not flag unchanged folders for update', () => {
|
||||
const folder = {
|
||||
name: 'Inbox',
|
||||
externalId: 'INBOX',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
};
|
||||
|
||||
const discoveredFolders = [folder];
|
||||
const existingFolders = [
|
||||
{
|
||||
...folder,
|
||||
id: 'folder-id',
|
||||
syncCursor: 'cursor',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
];
|
||||
|
||||
const result = computeFoldersToUpdate({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
});
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should treat empty string parentFolderId same as null', () => {
|
||||
const discoveredFolders = [
|
||||
{
|
||||
name: 'Folder',
|
||||
externalId: 'ext-1',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: '',
|
||||
},
|
||||
];
|
||||
|
||||
const existingFolders = [
|
||||
{
|
||||
id: 'folder-id',
|
||||
name: 'Folder',
|
||||
externalId: 'ext-1',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
syncCursor: 'cursor',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
];
|
||||
|
||||
const result = computeFoldersToUpdate({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
});
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { MessageFolderPendingSyncAction } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import { computeUpdatedFolders } from 'src/modules/messaging/message-folder-manager/utils/compute-updated-folders.util';
|
||||
|
||||
describe('computeUpdatedFolders', () => {
|
||||
it('should apply updates and set correct pendingSyncAction', () => {
|
||||
const existingFolders = [
|
||||
{
|
||||
id: 'folder-1',
|
||||
name: 'Old Name',
|
||||
externalId: 'ext-1',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
syncCursor: 'cursor',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
{
|
||||
id: 'folder-2',
|
||||
name: 'To Delete',
|
||||
externalId: 'ext-2',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
syncCursor: 'cursor',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
{
|
||||
id: 'folder-3',
|
||||
name: 'Unchanged',
|
||||
externalId: 'ext-3',
|
||||
isSynced: true,
|
||||
isSentFolder: false,
|
||||
parentFolderId: null,
|
||||
syncCursor: 'cursor',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
];
|
||||
|
||||
const foldersToUpdate = new Map([['folder-1', { name: 'New Name' }]]);
|
||||
const folderIdsToDelete = ['folder-2'];
|
||||
|
||||
const result = computeUpdatedFolders({
|
||||
existingFolders,
|
||||
foldersToUpdate,
|
||||
folderIdsToDelete,
|
||||
});
|
||||
|
||||
expect(result[0].name).toBe('New Name');
|
||||
expect(result[0].pendingSyncAction).toBe(
|
||||
MessageFolderPendingSyncAction.NONE,
|
||||
);
|
||||
|
||||
expect(result[1].pendingSyncAction).toBe(
|
||||
MessageFolderPendingSyncAction.FOLDER_DELETION,
|
||||
);
|
||||
|
||||
expect(result[2].name).toBe('Unchanged');
|
||||
expect(result[2].pendingSyncAction).toBe(
|
||||
MessageFolderPendingSyncAction.NONE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing properties when applying partial updates', () => {
|
||||
const existingFolders = [
|
||||
{
|
||||
id: 'folder-1',
|
||||
name: 'Original',
|
||||
externalId: 'ext-1',
|
||||
isSynced: true,
|
||||
isSentFolder: true,
|
||||
parentFolderId: 'parent-123',
|
||||
syncCursor: 'cursor-abc',
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
];
|
||||
|
||||
const foldersToUpdate = new Map([['folder-1', { name: 'Updated' }]]);
|
||||
|
||||
const result = computeUpdatedFolders({
|
||||
existingFolders,
|
||||
foldersToUpdate,
|
||||
folderIdsToDelete: [],
|
||||
});
|
||||
|
||||
expect(result[0].name).toBe('Updated');
|
||||
expect(result[0].isSentFolder).toBe(true);
|
||||
expect(result[0].parentFolderId).toBe('parent-123');
|
||||
expect(result[0].syncCursor).toBe('cursor-abc');
|
||||
});
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
type DiscoveredMessageFolder,
|
||||
type MessageFolder,
|
||||
} from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
export const computeFolderIdsToDelete = ({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
}: {
|
||||
discoveredFolders: DiscoveredMessageFolder[];
|
||||
existingFolders: MessageFolder[];
|
||||
}): string[] => {
|
||||
const discoveredExternalIds = new Set(
|
||||
discoveredFolders.map((discoveredFolder) => discoveredFolder.externalId),
|
||||
);
|
||||
|
||||
return existingFolders
|
||||
.filter(
|
||||
(existingFolder) => !discoveredExternalIds.has(existingFolder.externalId),
|
||||
)
|
||||
.map((existingFolder) => existingFolder.id);
|
||||
};
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import {
|
||||
type DiscoveredMessageFolder,
|
||||
type MessageFolder,
|
||||
} from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
|
||||
export const computeFoldersToCreate = ({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
messageChannelId,
|
||||
}: {
|
||||
discoveredFolders: DiscoveredMessageFolder[];
|
||||
existingFolders: MessageFolder[];
|
||||
messageChannelId: string;
|
||||
}): Partial<MessageFolderWorkspaceEntity>[] => {
|
||||
const existingFoldersByExternalId = new Map(
|
||||
existingFolders.map((folder) => [folder.externalId, folder]),
|
||||
);
|
||||
|
||||
return discoveredFolders
|
||||
.filter(
|
||||
(discoveredFolder) =>
|
||||
!existingFoldersByExternalId.has(discoveredFolder.externalId),
|
||||
)
|
||||
.map((discoveredFolder) => ({
|
||||
name: discoveredFolder.name,
|
||||
externalId: discoveredFolder.externalId,
|
||||
messageChannelId,
|
||||
isSentFolder: discoveredFolder.isSentFolder,
|
||||
isSynced: discoveredFolder.isSynced,
|
||||
syncCursor: null,
|
||||
parentFolderId: isNonEmptyString(discoveredFolder.parentFolderId)
|
||||
? discoveredFolder.parentFolderId
|
||||
: null,
|
||||
}));
|
||||
};
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import deepEqual from 'deep-equal';
|
||||
|
||||
import {
|
||||
type DiscoveredMessageFolder,
|
||||
type MessageFolder,
|
||||
} from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
|
||||
export const computeFoldersToUpdate = ({
|
||||
discoveredFolders,
|
||||
existingFolders,
|
||||
}: {
|
||||
discoveredFolders: DiscoveredMessageFolder[];
|
||||
existingFolders: MessageFolder[];
|
||||
}): Map<string, Partial<MessageFolderWorkspaceEntity>> => {
|
||||
const existingFoldersByExternalId = new Map(
|
||||
existingFolders.map((folder) => [folder.externalId, folder]),
|
||||
);
|
||||
|
||||
const foldersToUpdate = new Map<
|
||||
string,
|
||||
Partial<MessageFolderWorkspaceEntity>
|
||||
>();
|
||||
|
||||
for (const discoveredFolder of discoveredFolders) {
|
||||
const existingFolder = existingFoldersByExternalId.get(
|
||||
discoveredFolder.externalId,
|
||||
);
|
||||
|
||||
if (!existingFolder) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const discoveredFolderData = {
|
||||
name: discoveredFolder.name,
|
||||
isSentFolder: discoveredFolder.isSentFolder,
|
||||
parentFolderId: isNonEmptyString(discoveredFolder.parentFolderId)
|
||||
? discoveredFolder.parentFolderId
|
||||
: null,
|
||||
};
|
||||
|
||||
const existingFolderData = {
|
||||
name: existingFolder.name,
|
||||
isSentFolder: existingFolder.isSentFolder,
|
||||
parentFolderId: isNonEmptyString(existingFolder.parentFolderId)
|
||||
? existingFolder.parentFolderId
|
||||
: null,
|
||||
};
|
||||
|
||||
if (!deepEqual(discoveredFolderData, existingFolderData)) {
|
||||
foldersToUpdate.set(existingFolder.id, discoveredFolderData);
|
||||
}
|
||||
}
|
||||
|
||||
return foldersToUpdate;
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { type MessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
import {
|
||||
MessageFolderPendingSyncAction,
|
||||
type MessageFolderWorkspaceEntity,
|
||||
} from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
|
||||
export const computeUpdatedFolders = ({
|
||||
existingFolders,
|
||||
foldersToUpdate,
|
||||
folderIdsToDelete,
|
||||
}: {
|
||||
existingFolders: MessageFolder[];
|
||||
foldersToUpdate: Map<string, Partial<MessageFolderWorkspaceEntity>>;
|
||||
folderIdsToDelete: string[];
|
||||
}): MessageFolder[] => {
|
||||
return existingFolders.map((existingFolder) => {
|
||||
const update = foldersToUpdate.get(existingFolder.id);
|
||||
const isMarkedForDeletion = folderIdsToDelete.includes(existingFolder.id);
|
||||
|
||||
const pendingSyncAction = isMarkedForDeletion
|
||||
? MessageFolderPendingSyncAction.FOLDER_DELETION
|
||||
: MessageFolderPendingSyncAction.NONE;
|
||||
|
||||
if (update) {
|
||||
return {
|
||||
...existingFolder,
|
||||
...update,
|
||||
pendingSyncAction,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...existingFolder,
|
||||
pendingSyncAction,
|
||||
};
|
||||
});
|
||||
};
|
||||
+40
-16
@@ -1,7 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { batchFetchImplementation } from '@jrmdayn/googleapis-batcher';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { google, type gmail_v1 as gmailV1 } from 'googleapis';
|
||||
import { google } from 'googleapis';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
|
||||
@@ -19,6 +20,8 @@ import { type GetMessageListsArgs } from 'src/modules/messaging/message-import-m
|
||||
import { type GetMessageListsResponse } from 'src/modules/messaging/message-import-manager/types/get-message-lists-response.type';
|
||||
import { assertNotNull } from 'src/utils/assert';
|
||||
|
||||
const GMAIL_BATCH_REQUEST_MAX_SIZE = 50;
|
||||
|
||||
@Injectable()
|
||||
export class GmailGetMessageListService {
|
||||
private readonly logger = new Logger(GmailGetMessageListService.name);
|
||||
@@ -165,7 +168,7 @@ export class GmailGetMessageListService {
|
||||
await this.gmailGetHistoryService.getMessageIdsFromHistory(history);
|
||||
|
||||
const messageIdsToFilter = await this.getEmailIdsFromExcludedFolders(
|
||||
gmailClient,
|
||||
connectedAccount,
|
||||
messageChannel.syncCursor,
|
||||
messageFolders,
|
||||
);
|
||||
@@ -193,33 +196,54 @@ export class GmailGetMessageListService {
|
||||
}
|
||||
|
||||
private async getEmailIdsFromExcludedFolders(
|
||||
gmailClient: gmailV1.Gmail,
|
||||
connectedAccount: Pick<
|
||||
ConnectedAccountWorkspaceEntity,
|
||||
'provider' | 'accessToken' | 'refreshToken' | 'id' | 'handle'
|
||||
>,
|
||||
lastSyncHistoryId: string,
|
||||
messageFolders: Pick<
|
||||
MessageFolderWorkspaceEntity,
|
||||
'name' | 'externalId' | 'isSynced'
|
||||
>[],
|
||||
): Promise<string[]> {
|
||||
const emailIds: string[] = [];
|
||||
|
||||
const toBeExcludedFolders = messageFolders.filter(
|
||||
(folder) => !folder.isSynced,
|
||||
(folder) => !folder.isSynced && isDefined(folder.externalId),
|
||||
);
|
||||
|
||||
for (const folder of toBeExcludedFolders) {
|
||||
if (!isDefined(folder.externalId)) {
|
||||
continue;
|
||||
}
|
||||
if (toBeExcludedFolders.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { history } = await this.gmailGetHistoryService.getHistory(
|
||||
gmailClient,
|
||||
lastSyncHistoryId,
|
||||
['messageAdded'],
|
||||
folder.externalId,
|
||||
const oAuth2Client =
|
||||
await this.oAuth2ClientManagerService.getGoogleOAuth2Client(
|
||||
connectedAccount,
|
||||
);
|
||||
|
||||
const batchedFetchImplementation = batchFetchImplementation({
|
||||
maxBatchSize: GMAIL_BATCH_REQUEST_MAX_SIZE,
|
||||
});
|
||||
const batchedGmailClient = google.gmail({
|
||||
version: 'v1',
|
||||
auth: oAuth2Client,
|
||||
fetchImplementation: batchedFetchImplementation,
|
||||
});
|
||||
|
||||
const historyPromises = toBeExcludedFolders.map((folder) =>
|
||||
this.gmailGetHistoryService.getHistory(
|
||||
batchedGmailClient,
|
||||
lastSyncHistoryId,
|
||||
['messageAdded'],
|
||||
folder.externalId!,
|
||||
),
|
||||
);
|
||||
|
||||
const historyResults = await Promise.all(historyPromises);
|
||||
|
||||
const emailIds: string[] = [];
|
||||
|
||||
for (const { history } of historyResults) {
|
||||
const emailIdsFromCategory = history
|
||||
.map((history) => history.messagesAdded)
|
||||
.map((historyItem) => historyItem.messagesAdded)
|
||||
.flat()
|
||||
.map((message) => message?.message?.id)
|
||||
.filter((id) => id)
|
||||
|
||||
+84
-9
@@ -1,8 +1,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type ImapFlow } from 'imapflow';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { MessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import {
|
||||
MessageImportDriverException,
|
||||
MessageImportDriverExceptionCode,
|
||||
@@ -12,6 +14,7 @@ import { ImapMessageListFetchErrorHandler } from 'src/modules/messaging/message-
|
||||
import { ImapSyncService } from 'src/modules/messaging/message-import-manager/drivers/imap/services/imap-sync.service';
|
||||
import { createSyncCursor } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/create-sync-cursor.util';
|
||||
import { extractMailboxState } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/extract-mailbox-state.util';
|
||||
import { parseMessageId } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-message-id.util';
|
||||
import { parseSyncCursor } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/parse-sync-cursor.util';
|
||||
import { type GetMessageListsArgs } from 'src/modules/messaging/message-import-manager/types/get-message-lists-args.type';
|
||||
import {
|
||||
@@ -19,11 +22,6 @@ import {
|
||||
type GetOneMessageListResponse,
|
||||
} from 'src/modules/messaging/message-import-manager/types/get-message-lists-response.type';
|
||||
|
||||
type MessageFolder = Pick<
|
||||
MessageFolderWorkspaceEntity,
|
||||
'id' | 'name' | 'syncCursor' | 'externalId'
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class ImapGetMessageListService {
|
||||
private readonly logger = new Logger(ImapGetMessageListService.name);
|
||||
@@ -65,17 +63,31 @@ export class ImapGetMessageListService {
|
||||
client: ImapFlow,
|
||||
folder: MessageFolder,
|
||||
): Promise<GetOneMessageListResponse> {
|
||||
const folderPath = folder.externalId?.split(':')[0];
|
||||
const folderPath = parseMessageId(folder.externalId ?? '')?.folder;
|
||||
|
||||
if (!folderPath) {
|
||||
if (!isDefined(folderPath)) {
|
||||
throw new MessageImportDriverException(
|
||||
`Folder ${folder.name} has no path`,
|
||||
MessageImportDriverExceptionCode.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (await this.canSkipFolderSync(client, folder)) {
|
||||
this.logger.log(`Skipping folder ${folder.name}: no new messages`);
|
||||
|
||||
return {
|
||||
messageExternalIds: [],
|
||||
messageExternalIdsToDelete: [],
|
||||
nextSyncCursor: folder.syncCursor ?? '',
|
||||
previousSyncCursor: folder.syncCursor,
|
||||
folderId: folder.id,
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.log(`Processing folder: ${folder.name}`);
|
||||
|
||||
const previousCursor = parseSyncCursor(folder.syncCursor);
|
||||
|
||||
const lock = await client.getMailboxLock(folderPath);
|
||||
|
||||
try {
|
||||
@@ -89,7 +101,6 @@ export class ImapGetMessageListService {
|
||||
}
|
||||
|
||||
const mailboxState = extractMailboxState(mailbox);
|
||||
const previousCursor = parseSyncCursor(folder.syncCursor);
|
||||
|
||||
const { messageUids } = await this.imapSyncService.syncFolder(
|
||||
client,
|
||||
@@ -125,4 +136,68 @@ export class ImapGetMessageListService {
|
||||
lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async canSkipFolderSync(
|
||||
client: ImapFlow,
|
||||
folder: MessageFolder,
|
||||
): Promise<boolean> {
|
||||
const folderPath = parseMessageId(folder.externalId ?? '')?.folder;
|
||||
const previousCursor = parseSyncCursor(folder.syncCursor);
|
||||
|
||||
if (!isDefined(folderPath) || !isDefined(previousCursor)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const supportsCondstore = client.capabilities.has('CONDSTORE');
|
||||
|
||||
const status = await client.status(folderPath, {
|
||||
uidNext: true,
|
||||
uidValidity: true,
|
||||
...(supportsCondstore && { highestModseq: true }),
|
||||
});
|
||||
|
||||
if (!isDefined(status.uidValidity)) {
|
||||
this.logger.debug(
|
||||
`Folder ${folderPath}: Server missing UIDVALIDITY. Sync required.`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const uidNext = Number(status.uidNext ?? 1);
|
||||
const uidValidity = Number(status.uidValidity);
|
||||
|
||||
if (previousCursor.uidValidity !== uidValidity) {
|
||||
this.logger.debug(
|
||||
`Folder ${folderPath}: UIDVALIDITY changed (${previousCursor.uidValidity} → ${uidValidity}). Full sync required.`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasModSeqChanged =
|
||||
isDefined(previousCursor.modSeq) &&
|
||||
isDefined(status.highestModseq) &&
|
||||
previousCursor.modSeq !== status.highestModseq.toString();
|
||||
|
||||
if (hasModSeqChanged) {
|
||||
this.logger.debug(
|
||||
`Folder ${folderPath}: MODSEQ changed (${previousCursor.modSeq} → ${status.highestModseq}). Sync required.`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxUid = Math.max(0, uidNext - 1);
|
||||
|
||||
return previousCursor.highestUid >= maxUid;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to get status for folder ${folderPath}: ${error.message}`,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -8,7 +8,10 @@ import { MicrosoftOAuth2ClientManagerService } from 'src/modules/connected-accou
|
||||
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
import { MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import {
|
||||
MessageFolderPendingSyncAction,
|
||||
MessageFolderWorkspaceEntity,
|
||||
} from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import { microsoftGraphWithMessagesDeltaLink } from 'src/modules/messaging/message-import-manager/drivers/microsoft/mocks/microsoft-api-examples';
|
||||
import { MessageFolderName } from 'src/modules/messaging/message-import-manager/drivers/microsoft/types/folders';
|
||||
|
||||
@@ -79,6 +82,8 @@ xdescribe('Microsoft dev tests : get message list service', () => {
|
||||
isSynced: false,
|
||||
isSentFolder: false,
|
||||
externalId: null,
|
||||
parentFolderId: null,
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -108,6 +113,8 @@ xdescribe('Microsoft dev tests : get message list service', () => {
|
||||
isSynced: false,
|
||||
isSentFolder: false,
|
||||
externalId: null,
|
||||
parentFolderId: null,
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -127,6 +134,8 @@ xdescribe('Microsoft dev tests : get message list service', () => {
|
||||
isSynced: false,
|
||||
isSentFolder: false,
|
||||
externalId: null,
|
||||
parentFolderId: null,
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -150,6 +159,8 @@ xdescribe('Microsoft dev tests : get message list service', () => {
|
||||
isSynced: false,
|
||||
isSentFolder: false,
|
||||
externalId: null,
|
||||
parentFolderId: null,
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -168,6 +179,7 @@ xdescribe('Microsoft dev tests : get message list service for folders', () => {
|
||||
inboxFolder.name = MessageFolderName.INBOX;
|
||||
inboxFolder.syncCursor = 'inbox-sync-cursor';
|
||||
inboxFolder.messageChannelId = 'message-channel-1';
|
||||
inboxFolder.parentFolderId = null;
|
||||
|
||||
const sentFolder = new MessageFolderWorkspaceEntity();
|
||||
|
||||
@@ -175,6 +187,7 @@ xdescribe('Microsoft dev tests : get message list service for folders', () => {
|
||||
sentFolder.name = MessageFolderName.SENT_ITEMS;
|
||||
sentFolder.syncCursor = 'sent-sync-cursor';
|
||||
sentFolder.messageChannelId = 'message-channel-1';
|
||||
sentFolder.parentFolderId = null;
|
||||
|
||||
const otherFolder = new MessageFolderWorkspaceEntity();
|
||||
|
||||
@@ -182,6 +195,7 @@ xdescribe('Microsoft dev tests : get message list service for folders', () => {
|
||||
otherFolder.name = 'other';
|
||||
otherFolder.syncCursor = 'other-sync-cursor';
|
||||
otherFolder.messageChannelId = 'message-channel-2';
|
||||
otherFolder.parentFolderId = null;
|
||||
|
||||
const messageChannelNoFolders = new MessageChannelWorkspaceEntity();
|
||||
|
||||
|
||||
+18
-18
@@ -6,7 +6,7 @@ import {
|
||||
type PageIteratorCallback,
|
||||
} from '@microsoft/microsoft-graph-client';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import pLimit from 'p-limit';
|
||||
|
||||
import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2-client-manager/services/oauth2-client-manager.service';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
// Microsoft API limit is 999 messages per request on this endpoint
|
||||
const MESSAGING_MICROSOFT_USERS_MESSAGES_LIST_MAX_RESULT = 999;
|
||||
|
||||
/* reference: https://learn.microsoft.com/en-us/graph/throttling-limits#limits-per-mailbox */
|
||||
const FOLDER_PROCESSING_CONCURRENCY = 4;
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftGetMessageListService {
|
||||
private readonly logger = new Logger(MicrosoftGetMessageListService.name);
|
||||
@@ -38,8 +41,6 @@ export class MicrosoftGetMessageListService {
|
||||
connectedAccount,
|
||||
messageFolders,
|
||||
}: GetMessageListsArgs): Promise<GetMessageListsResponse> {
|
||||
const result: GetMessageListsResponse = [];
|
||||
|
||||
if (messageFolders.length === 0) {
|
||||
throw new MessageImportDriverException(
|
||||
`Message channel ${messageChannel.id} has no message folders`,
|
||||
@@ -47,16 +48,22 @@ export class MicrosoftGetMessageListService {
|
||||
);
|
||||
}
|
||||
|
||||
for (const folder of messageFolders) {
|
||||
const response = await this.getMessageList(connectedAccount, folder);
|
||||
const limit = pLimit(FOLDER_PROCESSING_CONCURRENCY);
|
||||
|
||||
result.push({
|
||||
...response,
|
||||
folderId: folder.id,
|
||||
});
|
||||
}
|
||||
const results = await Promise.all(
|
||||
messageFolders.map((folder) =>
|
||||
limit(async () => {
|
||||
const response = await this.getMessageList(connectedAccount, folder);
|
||||
|
||||
return result;
|
||||
return {
|
||||
...response,
|
||||
folderId: folder.id,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async getMessageList(
|
||||
@@ -116,13 +123,6 @@ export class MicrosoftGetMessageListService {
|
||||
this.microsoftMessageListFetchErrorHandler.handleError(error);
|
||||
});
|
||||
|
||||
if (!isDefined(messageFolder.syncCursor)) {
|
||||
throw new MessageImportDriverException(
|
||||
'Message folder sync cursor is required',
|
||||
MessageImportDriverExceptionCode.SYNC_CURSOR_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
messageExternalIds,
|
||||
messageExternalIdsToDelete,
|
||||
|
||||
+18
-3
@@ -7,7 +7,10 @@ import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/typ
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
|
||||
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import {
|
||||
MessageFolderPendingSyncAction,
|
||||
type MessageFolderWorkspaceEntity,
|
||||
} from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import { MessagingMessageCleanerService } from 'src/modules/messaging/message-cleaner/services/messaging-message-cleaner.service';
|
||||
import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service';
|
||||
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
|
||||
@@ -64,7 +67,8 @@ describe('MessagingMessageListFetchService', () => {
|
||||
handleAliases: '',
|
||||
},
|
||||
syncCursor: 'google-sync-cursor',
|
||||
} as MessageChannelWorkspaceEntity;
|
||||
messageFolders: [],
|
||||
} as unknown as MessageChannelWorkspaceEntity;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -248,7 +252,16 @@ describe('MessagingMessageListFetchService', () => {
|
||||
{
|
||||
provide: SyncMessageFoldersService,
|
||||
useValue: {
|
||||
syncMessageFolders: jest.fn().mockResolvedValue(undefined),
|
||||
syncMessageFolders: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'inbox-folder-id',
|
||||
name: 'inbox',
|
||||
syncCursor: 'inbox-sync-cursor',
|
||||
messageChannelId: 'microsoft-message-channel-id',
|
||||
isSynced: true,
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -323,6 +336,7 @@ describe('MessagingMessageListFetchService', () => {
|
||||
syncCursor: 'inbox-sync-cursor',
|
||||
messageChannelId: 'microsoft-message-channel-id',
|
||||
isSynced: true,
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
],
|
||||
);
|
||||
@@ -384,6 +398,7 @@ describe('MessagingMessageListFetchService', () => {
|
||||
syncCursor: 'inbox-sync-cursor',
|
||||
messageChannelId: 'microsoft-message-channel-id',
|
||||
isSynced: true,
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
+2
-6
@@ -2,8 +2,9 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
|
||||
import { MessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
import { MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import {
|
||||
MessageImportDriverException,
|
||||
MessageImportDriverExceptionCode,
|
||||
@@ -13,11 +14,6 @@ import { ImapGetMessageListService } from 'src/modules/messaging/message-import-
|
||||
import { MicrosoftGetMessageListService } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-get-message-list.service';
|
||||
import { type GetMessageListsResponse } from 'src/modules/messaging/message-import-manager/types/get-message-lists-response.type';
|
||||
|
||||
type MessageFolder = Pick<
|
||||
MessageFolderWorkspaceEntity,
|
||||
'name' | 'isSynced' | 'isSentFolder' | 'externalId' | 'syncCursor' | 'id'
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class MessagingGetMessageListService {
|
||||
constructor(
|
||||
|
||||
+16
-39
@@ -8,7 +8,6 @@ import { In, MoreThanOrEqual } from 'typeorm';
|
||||
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
|
||||
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
|
||||
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
|
||||
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
|
||||
@@ -19,10 +18,7 @@ import {
|
||||
MessageChannelWorkspaceEntity,
|
||||
MessageFolderImportPolicy,
|
||||
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
import {
|
||||
MessageFolderPendingSyncAction,
|
||||
MessageFolderWorkspaceEntity,
|
||||
} from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import { MessageFolderPendingSyncAction } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
import { MessagingMessageCleanerService } from 'src/modules/messaging/message-cleaner/services/messaging-message-cleaner.service';
|
||||
import { SyncMessageFoldersService } from 'src/modules/messaging/message-folder-manager/services/sync-message-folders.service';
|
||||
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
|
||||
@@ -127,33 +123,21 @@ export class MessagingMessageListFetchService {
|
||||
},
|
||||
};
|
||||
|
||||
const datasource =
|
||||
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
|
||||
|
||||
await this.syncMessageFoldersService.syncMessageFolders({
|
||||
workspaceId,
|
||||
messageChannel: messageChannelWithFreshTokens,
|
||||
manager: datasource.manager as WorkspaceEntityManager,
|
||||
});
|
||||
|
||||
const messageFolderRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<MessageFolderWorkspaceEntity>(
|
||||
const messageFolders =
|
||||
await this.syncMessageFoldersService.syncMessageFolders({
|
||||
messageChannel: messageChannelWithFreshTokens,
|
||||
workspaceId,
|
||||
'messageFolder',
|
||||
);
|
||||
});
|
||||
|
||||
const messageFolders = await messageFolderRepository.find({
|
||||
where: {
|
||||
messageChannelId: freshMessageChannel.id,
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.NONE,
|
||||
},
|
||||
});
|
||||
|
||||
const messageFoldersToSync =
|
||||
const messageFoldersToSync = (
|
||||
messageChannelWithFreshTokens.messageFolderImportPolicy ===
|
||||
MessageFolderImportPolicy.ALL_FOLDERS
|
||||
? messageFolders
|
||||
: messageFolders.filter((folder) => folder.isSynced);
|
||||
: messageFolders.filter((folder) => folder.isSynced)
|
||||
).filter(
|
||||
(folder) =>
|
||||
folder.pendingSyncAction === MessageFolderPendingSyncAction.NONE,
|
||||
);
|
||||
|
||||
const messageLists =
|
||||
await this.messagingGetMessageListService.getMessageLists(
|
||||
@@ -360,18 +344,11 @@ export class MessagingMessageListFetchService {
|
||||
messageChannel: MessageChannelWorkspaceEntity,
|
||||
workspaceId: string,
|
||||
): Promise<boolean> {
|
||||
const messageFolderRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<MessageFolderWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'messageFolder',
|
||||
);
|
||||
|
||||
const foldersWithPendingActions = await messageFolderRepository.find({
|
||||
where: {
|
||||
messageChannelId: messageChannel.id,
|
||||
pendingSyncAction: MessageFolderPendingSyncAction.FOLDER_DELETION,
|
||||
},
|
||||
});
|
||||
const foldersWithPendingActions = messageChannel.messageFolders.filter(
|
||||
(folder) =>
|
||||
isDefined(folder.pendingSyncAction) &&
|
||||
folder.pendingSyncAction !== MessageFolderPendingSyncAction.NONE,
|
||||
);
|
||||
|
||||
if (foldersWithPendingActions.length === 0) {
|
||||
return false;
|
||||
|
||||
+3
-5
@@ -1,6 +1,7 @@
|
||||
import { type MessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
|
||||
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
|
||||
|
||||
export type GetMessageListsArgs = {
|
||||
messageChannel: Pick<MessageChannelWorkspaceEntity, 'syncCursor' | 'id'>;
|
||||
@@ -13,8 +14,5 @@ export type GetMessageListsArgs = {
|
||||
| 'handle'
|
||||
| 'connectionParameters'
|
||||
>;
|
||||
messageFolders: Pick<
|
||||
MessageFolderWorkspaceEntity,
|
||||
'name' | 'syncCursor' | 'id' | 'isSynced' | 'isSentFolder' | 'externalId'
|
||||
>[];
|
||||
messageFolders: MessageFolder[];
|
||||
};
|
||||
|
||||
@@ -9726,6 +9726,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jrmdayn/googleapis-batcher@npm:^0.10.1":
|
||||
version: 0.10.1
|
||||
resolution: "@jrmdayn/googleapis-batcher@npm:0.10.1"
|
||||
dependencies:
|
||||
dataloader: "npm:^2.2.1"
|
||||
debug: "npm:^4.3.4"
|
||||
handlebars: "npm:^4.7.7"
|
||||
next-line: "npm:^1.1.0"
|
||||
node-fetch: "npm:2"
|
||||
peerDependencies:
|
||||
gaxios: ">= 5"
|
||||
googleapis: ">= 109"
|
||||
checksum: 10c0/ffc61154fa03227c3c865cc44f33d545a50b66804c87e3d93b28afe9d297fd6ac91bd55205d0d414aba6f907840ca2f22ec5531617fd09c184811e6eba388ab1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@js-sdsl/ordered-map@npm:^4.4.2":
|
||||
version: 4.4.2
|
||||
resolution: "@js-sdsl/ordered-map@npm:4.4.2"
|
||||
@@ -32098,6 +32114,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dataloader@npm:^2.2.1":
|
||||
version: 2.2.3
|
||||
resolution: "dataloader@npm:2.2.3"
|
||||
checksum: 10c0/9b9a056fbc863ca86da87d59e053e871e263b4966aa4d55e40d61a65e96815fae5530ca220629064ca5f8e3000c0c4ec93292e170c38ff393fb34256b4d7c1aa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"date-fns-tz@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "date-fns-tz@npm:2.0.1"
|
||||
@@ -37595,7 +37618,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"handlebars@npm:*, handlebars@npm:^4.7.8":
|
||||
"handlebars@npm:*, handlebars@npm:^4.7.7, handlebars@npm:^4.7.8":
|
||||
version: 4.7.8
|
||||
resolution: "handlebars@npm:4.7.8"
|
||||
dependencies:
|
||||
@@ -45952,6 +45975,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next-line@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "next-line@npm:1.1.0"
|
||||
checksum: 10c0/c17aed62e7af05e612cb852d601298f8bef47822231f06fb68c4a8ae7b272c2d49923eefcff56082f173da36a1b1cbd00dad11b05559957d268c38daab2e721b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next-mdx-remote-client@npm:^1.0.3":
|
||||
version: 1.1.4
|
||||
resolution: "next-mdx-remote-client@npm:1.1.4"
|
||||
@@ -46255,6 +46285,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-fetch@npm:2, node-fetch@npm:2.7.0, node-fetch@npm:^2.6.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9, node-fetch@npm:^2.7.0":
|
||||
version: 2.7.0
|
||||
resolution: "node-fetch@npm:2.7.0"
|
||||
dependencies:
|
||||
whatwg-url: "npm:^5.0.0"
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-fetch@npm:2.6.7":
|
||||
version: 2.6.7
|
||||
resolution: "node-fetch@npm:2.6.7"
|
||||
@@ -46269,20 +46313,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-fetch@npm:2.7.0, node-fetch@npm:^2.6.0, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.12, node-fetch@npm:^2.6.7, node-fetch@npm:^2.6.9, node-fetch@npm:^2.7.0":
|
||||
version: 2.7.0
|
||||
resolution: "node-fetch@npm:2.7.0"
|
||||
dependencies:
|
||||
whatwg-url: "npm:^5.0.0"
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"node-forge@npm:^1.3.1":
|
||||
version: 1.3.2
|
||||
resolution: "node-forge@npm:1.3.2"
|
||||
@@ -56494,6 +56524,7 @@ __metadata:
|
||||
"@graphql-tools/schema": "npm:10.0.4"
|
||||
"@graphql-tools/utils": "npm:9.2.1"
|
||||
"@graphql-yoga/nestjs": "patch:@graphql-yoga/nestjs@2.1.0#./patches/@graphql-yoga+nestjs+2.1.0.patch"
|
||||
"@jrmdayn/googleapis-batcher": "npm:^0.10.1"
|
||||
"@lingui/cli": "npm:^5.1.2"
|
||||
"@lingui/conf": "npm:5.1.2"
|
||||
"@lingui/core": "npm:^5.1.2"
|
||||
|
||||
Reference in New Issue
Block a user