Child folders followup (#15526)

This commit is contained in:
neo773
2025-11-04 19:34:03 +05:30
committed by GitHub
parent 15a6ca64da
commit e3076328dd
22 changed files with 736 additions and 121 deletions
@@ -91,6 +91,17 @@ export class MessageFolderWorkspaceEntity extends BaseWorkspaceEntity {
})
isSynced: boolean;
@WorkspaceField({
standardId: MESSAGE_FOLDER_STANDARD_FIELD_IDS.parentFolderId,
type: FieldMetadataType.TEXT,
label: msg`Parent Folder ID`,
description: msg`Parent Folder ID`,
icon: 'IconFolder',
defaultValue: null,
})
@WorkspaceIsNullable()
parentFolderId: string | null;
@WorkspaceField({
standardId: MESSAGE_FOLDER_STANDARD_FIELD_IDS.externalId,
type: FieldMetadataType.TEXT,
@@ -7,6 +7,8 @@ import {
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 { extractGmailFolderName } from 'src/modules/messaging/message-folder-manager/drivers/gmail/utils/extract-gmail-folder-name.util';
import { getGmailFolderParentId } from 'src/modules/messaging/message-folder-manager/drivers/gmail/utils/get-gmail-folder-parent-id.util';
import { MESSAGING_GMAIL_DEFAULT_NOT_SYNCED_LABELS } from 'src/modules/messaging/message-import-manager/drivers/gmail/constants/messaging-gmail-default-not-synced-labels';
import { GmailMessageListFetchErrorHandler } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-message-list-fetch-error-handler.service';
@@ -53,18 +55,34 @@ export class GmailGetAllFoldersService implements MessageFolderDriver {
const folders: MessageFolder[] = [];
const labelNameToIdMap = new Map<string, string>();
for (const label of labels) {
if (!label.name || !label.id) {
continue;
}
labelNameToIdMap.set(label.name, label.id);
}
for (const label of labels) {
if (!label.name || !label.id) {
continue;
}
const isSentFolder = label.id === 'SENT';
const folderName = extractGmailFolderName(label.name);
const parentFolderId = getGmailFolderParentId(
label.name,
labelNameToIdMap,
);
folders.push({
externalId: label.id,
name: label.name,
name: folderName,
isSynced: this.isSyncedByDefault(label.id),
isSentFolder,
parentFolderId,
});
}
@@ -0,0 +1,30 @@
import { extractGmailFolderName } from 'src/modules/messaging/message-folder-manager/drivers/gmail/utils/extract-gmail-folder-name.util';
describe('extractGmailFolderName', () => {
it('should return full name for top-level folders', () => {
expect(extractGmailFolderName('Inbox')).toBe('Inbox');
expect(extractGmailFolderName('Sent')).toBe('Sent');
});
it('should extract folder name from nested folder', () => {
expect(extractGmailFolderName('Work/Projects')).toBe('Projects');
});
it('should extract folder name from deeply nested folder', () => {
expect(extractGmailFolderName('Work/Projects/2024')).toBe('2024');
});
it('should handle Gmail-style nested labels', () => {
expect(extractGmailFolderName('[Gmail]/Sent Mail')).toBe('Sent Mail');
});
it('should handle single character names', () => {
expect(extractGmailFolderName('A/B/C')).toBe('C');
});
it('should handle special characters', () => {
expect(extractGmailFolderName('Work/Client - ABC Corp')).toBe(
'Client - ABC Corp',
);
});
});
@@ -0,0 +1,56 @@
import { getGmailFolderParentId } from 'src/modules/messaging/message-folder-manager/drivers/gmail/utils/get-gmail-folder-parent-id.util';
describe('getGmailFolderParentId', () => {
it('should return null for top-level folders without slash', () => {
const labelNameToIdMap = new Map<string, string>([
['Inbox', 'INBOX'],
['Sent', 'SENT'],
]);
expect(getGmailFolderParentId('Inbox', labelNameToIdMap)).toBeNull();
});
it('should return parent ID for nested folder', () => {
const labelNameToIdMap = new Map<string, string>([
['Work', 'work-id'],
['Work/Projects', 'projects-id'],
]);
expect(getGmailFolderParentId('Work/Projects', labelNameToIdMap)).toBe(
'work-id',
);
});
it('should return parent ID for deeply nested folder', () => {
const labelNameToIdMap = new Map<string, string>([
['Work', 'work-id'],
['Work/Projects', 'projects-id'],
['Work/Projects/2024', '2024-id'],
]);
expect(getGmailFolderParentId('Work/Projects/2024', labelNameToIdMap)).toBe(
'projects-id',
);
});
it('should return null if parent folder does not exist in map', () => {
const labelNameToIdMap = new Map<string, string>([
['Work/Projects', 'projects-id'],
]);
expect(
getGmailFolderParentId('Work/Projects', labelNameToIdMap),
).toBeNull();
});
it('should handle Gmail-style nested labels', () => {
const labelNameToIdMap = new Map<string, string>([
['[Gmail]', 'gmail-id'],
['[Gmail]/Sent Mail', 'sent-id'],
]);
expect(getGmailFolderParentId('[Gmail]/Sent Mail', labelNameToIdMap)).toBe(
'gmail-id',
);
});
});
@@ -0,0 +1,7 @@
export const extractGmailFolderName = (labelName: string): string => {
if (!labelName.includes('/')) {
return labelName;
}
return labelName.substring(labelName.lastIndexOf('/') + 1);
};
@@ -0,0 +1,11 @@
export const getGmailFolderParentId = (
labelName: string,
labelNameToIdMap: Map<string, string>,
): string | null => {
if (!labelName.includes('/')) {
return null;
}
const parentName = labelName.substring(0, labelName.lastIndexOf('/'));
return labelNameToIdMap.get(parentName) || null;
};
@@ -55,47 +55,64 @@ export class ImapGetAllFoldersService implements MessageFolderDriver {
mailboxList: ListResponse[],
): Promise<MessageFolder[]> {
const folders: MessageFolder[] = [];
const sentFolderPath =
const pathToExternalIdMap = new Map<string, string>();
const sentFolder =
await this.imapFindSentFolderService.findSentFolder(client);
if (isDefined(sentFolderPath)) {
const sentMailbox = mailboxList.find((m) => m.path === sentFolderPath);
if (isDefined(sentFolder)) {
const sentMailbox = mailboxList.find((m) => m.path === sentFolder.path);
const uidValidity = sentMailbox
? await this.getUidValidity(client, sentMailbox)
: null;
const externalId = uidValidity
? `${sentFolder.path}:${uidValidity.toString()}`
: sentFolder.path;
pathToExternalIdMap.set(sentFolder.path, externalId);
folders.push({
externalId: uidValidity
? `${sentFolderPath}:${uidValidity.toString()}`
: sentFolderPath,
name: sentFolderPath,
externalId,
name: sentFolder.name,
isSynced: true,
isSentFolder: true,
parentFolderId: sentMailbox?.parentPath || null,
});
}
const validMailboxes = mailboxList.filter((mailbox) =>
this.isValidMailbox(mailbox, folders),
);
for (const mailbox of validMailboxes) {
const isInbox = await this.isInboxFolder(mailbox);
for (const mailbox of mailboxList) {
const uidValidity = await this.getUidValidity(client, mailbox);
const standardFolder = getStandardFolderByRegex(mailbox.path);
const isSynced = this.shouldSyncByDefault(
mailbox,
standardFolder,
isInbox,
);
const externalId = uidValidity
? `${mailbox.path}:${uidValidity}`
: mailbox.path;
folders.push({
externalId: uidValidity
? `${mailbox.path}:${uidValidity}`
: mailbox.path,
name: mailbox.path,
isSynced,
isSentFolder: false,
});
pathToExternalIdMap.set(mailbox.path, externalId);
if (this.isValidMailbox(mailbox, folders)) {
const isInbox = await this.isInboxFolder(mailbox);
const standardFolder = getStandardFolderByRegex(mailbox.path);
const isSynced = this.shouldSyncByDefault(
mailbox,
standardFolder,
isInbox,
);
folders.push({
externalId,
name: mailbox.name,
isSynced,
isSentFolder: false,
parentFolderId: mailbox.parentPath || null,
});
}
}
for (const folder of folders) {
if (folder.parentFolderId) {
const parentExternalId = pathToExternalIdMap.get(folder.parentFolderId);
folder.parentFolderId = parentExternalId || null;
}
}
return folders;
@@ -109,9 +126,15 @@ export class ImapGetAllFoldersService implements MessageFolderDriver {
return false;
}
const isDuplicate = existingFolders.some(
(folder) => folder.name === mailbox.path,
);
const isDuplicate = existingFolders.some((folder) => {
const folderPath = folder?.externalId?.split(':')[0];
if (!isDefined(folderPath)) {
return false;
}
return folderPath === mailbox.path;
});
return !isDuplicate;
}
@@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
MessageFolder,
MessageFolderDriver,
@@ -9,13 +11,13 @@ import { OAuth2ClientManagerService } from 'src/modules/connected-account/oauth2
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MicrosoftMessageListFetchErrorHandler } from 'src/modules/messaging/message-import-manager/drivers/microsoft/services/microsoft-message-list-fetch-error-handler.service';
import { StandardFolder } from 'src/modules/messaging/message-import-manager/drivers/types/standard-folder';
import { getStandardFolderByRegex } from 'src/modules/messaging/message-import-manager/drivers/utils/get-standard-folder-by-regex';
type MicrosoftGraphFolder = {
id: string;
displayName: string;
childFolderCount?: number;
parentFolderId?: string;
wellKnownName?: string;
};
const MESSAGING_MICROSOFT_MAIL_FOLDERS_LIST_MAX_RESULT = 999;
@@ -56,6 +58,7 @@ export class MicrosoftGetAllFoldersService implements MessageFolderDriver {
});
const folders = (response.value as MicrosoftGraphFolder[]) || [];
const rootFolderId = this.getRootFolderId(folders);
const folderInfos: MessageFolder[] = [];
for (const folder of folders) {
@@ -63,7 +66,9 @@ export class MicrosoftGetAllFoldersService implements MessageFolderDriver {
continue;
}
const standardFolder = getStandardFolderByRegex(folder.displayName);
const standardFolder = this.getStandardFolderFromWellKnownName(
folder.wellKnownName,
);
const isSentFolder = this.isSentFolder(standardFolder);
const isSynced = this.shouldSyncByDefault(standardFolder);
@@ -72,6 +77,10 @@ export class MicrosoftGetAllFoldersService implements MessageFolderDriver {
name: folder.displayName,
isSynced,
isSentFolder,
parentFolderId: this.getParentFolderId(
folder.parentFolderId,
rootFolderId,
),
});
}
@@ -105,4 +114,57 @@ export class MicrosoftGetAllFoldersService implements MessageFolderDriver {
return true;
}
private getStandardFolderFromWellKnownName(
wellKnownName?: string,
): StandardFolder | null {
if (!isDefined(wellKnownName)) {
return null;
}
switch (wellKnownName.toLowerCase()) {
case 'inbox':
return StandardFolder.INBOX;
case 'drafts':
return StandardFolder.DRAFTS;
case 'sentitems':
return StandardFolder.SENT;
case 'deleteditems':
return StandardFolder.TRASH;
case 'junkemail':
return StandardFolder.JUNK;
default:
return null;
}
}
/*
* All Microsoft folders have a parentFolderId including the standard folders
* which point to root node which doesn't exits in the API response.
* We remove this to simplify the folder hierarchy on frontend.
*/
private getRootFolderId(folders: MicrosoftGraphFolder[]): string | null {
for (const folder of folders) {
if (isDefined(folder.wellKnownName) && isDefined(folder.parentFolderId)) {
return folder.parentFolderId;
}
}
return null;
}
private getParentFolderId(
parentFolderId: string | undefined,
rootFolderId: string | null,
): string | null {
if (!isDefined(parentFolderId)) {
return null;
}
if (parentFolderId === rootFolderId) {
return null;
}
return parentFolderId;
}
}
@@ -3,7 +3,7 @@ import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/
export type MessageFolder = Pick<
MessageFolderWorkspaceEntity,
'name' | 'isSynced' | 'isSentFolder' | 'externalId'
'name' | 'isSynced' | 'isSentFolder' | 'externalId' | 'parentFolderId'
>;
export interface MessageFolderDriver {
@@ -32,10 +32,14 @@ type MessageFolderToInsert = Pick<
| 'isSynced'
| 'isSentFolder'
| 'externalId'
| 'parentFolderId'
>;
type MessageFolderToUpdate = Partial<
Pick<MessageFolderWorkspaceEntity, 'name' | 'externalId' | 'isSentFolder'>
Pick<
MessageFolderWorkspaceEntity,
'name' | 'externalId' | 'isSentFolder' | 'parentFolderId'
>
>;
@Injectable()
@@ -114,6 +118,7 @@ export class SyncMessageFoldersService {
name: folder.name,
externalId: folder.externalId,
isSentFolder: folder.isSentFolder,
parentFolderId: folder.parentFolderId,
},
]);
continue;
@@ -127,6 +132,7 @@ export class SyncMessageFoldersService {
isSynced: folder.isSynced,
isSentFolder: folder.isSentFolder,
externalId: folder.externalId,
parentFolderId: folder.parentFolderId,
});
}
@@ -5,6 +5,11 @@ import { ListResponse, type ImapFlow } from 'imapflow';
import { getImapSentFolderCandidatesByRegex } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/get-sent-folder-candidates-by-regex.util';
type SentFolderResult = {
name: string;
path: string;
} | null;
/**
* Service to find sent folder using IMAP special-use flags
*
@@ -19,7 +24,7 @@ import { getImapSentFolderCandidatesByRegex } from 'src/modules/messaging/messag
export class ImapFindSentFolderService {
private readonly logger = new Logger(ImapFindSentFolderService.name);
public async findSentFolder(client: ImapFlow): Promise<string | null> {
public async findSentFolder(client: ImapFlow): Promise<SentFolderResult> {
try {
const list = await client.list();
@@ -60,7 +65,7 @@ export class ImapFindSentFolderService {
private async findSentFolderBySpecialUse(
client: ImapFlow,
list: ListResponse[],
): Promise<string | null> {
): Promise<SentFolderResult> {
for (const folder of list) {
if (folder.specialUse && folder.specialUse.includes('\\Sent')) {
this.logger.log(
@@ -73,7 +78,10 @@ export class ImapFindSentFolderService {
);
if (messageCount > 0) {
return folder.path;
return {
name: folder.name,
path: folder.path,
};
}
this.logger.warn(
@@ -90,25 +98,38 @@ export class ImapFindSentFolderService {
private async findSentFolderByRegexCandidates(
client: ImapFlow,
list: ListResponse[],
): Promise<string | null> {
): Promise<SentFolderResult> {
const regexCandidateFolders = getImapSentFolderCandidatesByRegex(list);
for (const folder of regexCandidateFolders) {
const messageCount = await this.getFolderMessageCount(client, folder);
const messageCount = await this.getFolderMessageCount(
client,
folder.path,
);
if (messageCount > 0) {
this.logger.log(`Selected sent folder via pattern match: ${folder}`);
this.logger.log(
`Selected sent folder via pattern match: ${folder.path}`,
);
return folder;
return {
name: folder.name,
path: folder.path,
};
}
}
if (regexCandidateFolders.length > 0) {
this.logger.log(
`Using first regex candidate sent folder: ${regexCandidateFolders[0]} (no messages found in any regex candidate)`,
`Using first regex candidate sent folder: ${regexCandidateFolders[0].path} (no messages found in any regex candidate)`,
);
return regexCandidateFolders[0];
const folder = regexCandidateFolders[0];
return {
name: folder.name,
path: folder.path,
};
}
return null;
@@ -41,10 +41,17 @@ export class ImapGetMessageListService {
for (const folder of messageFolders) {
this.logger.log(`Processing folder: ${folder.name}`);
const folderPath = folder.externalId?.split(':')[0];
if (!folderPath) {
this.logger.warn(`Folder ${folder.name} has no path. Skipping.`);
continue;
}
try {
const response = await this.getMessageList(
client,
folder.name,
folderPath,
folder,
);
@@ -3,7 +3,7 @@ import { type ListResponse } from 'imapflow';
import { getImapSentFolderCandidatesByRegex } from 'src/modules/messaging/message-import-manager/drivers/imap/utils/get-sent-folder-candidates-by-regex.util';
function makeList(paths: string[]): ListResponse[] {
return paths.map((p) => ({ path: p }) as ListResponse);
return paths.map((p) => ({ path: p, name: p }) as ListResponse);
}
describe('getSentFolderCandidatesByRegex', () => {
@@ -18,7 +18,9 @@ describe('getSentFolderCandidatesByRegex', () => {
const input = makeList(englishVariants);
const result = getImapSentFolderCandidatesByRegex(input);
expect(result).toEqual(expect.arrayContaining(englishVariants));
expect(result.map((r) => r.path)).toEqual(
expect.arrayContaining(englishVariants),
);
});
it('matches French variants', () => {
@@ -26,7 +28,9 @@ describe('getSentFolderCandidatesByRegex', () => {
const input = makeList(frenchVariants);
const result = getImapSentFolderCandidatesByRegex(input);
expect(result).toEqual(expect.arrayContaining(frenchVariants));
expect(result.map((r) => r.path)).toEqual(
expect.arrayContaining(frenchVariants),
);
});
it('matches German variants', () => {
@@ -34,7 +38,9 @@ describe('getSentFolderCandidatesByRegex', () => {
const input = makeList(germanVariants);
const result = getImapSentFolderCandidatesByRegex(input);
expect(result).toEqual(expect.arrayContaining(germanVariants));
expect(result.map((r) => r.path)).toEqual(
expect.arrayContaining(germanVariants),
);
});
it('matches Spanish variants', () => {
@@ -42,7 +48,9 @@ describe('getSentFolderCandidatesByRegex', () => {
const input = makeList(spanishVariants);
const result = getImapSentFolderCandidatesByRegex(input);
expect(result).toEqual(expect.arrayContaining(spanishVariants));
expect(result.map((r) => r.path)).toEqual(
expect.arrayContaining(spanishVariants),
);
});
it('matches Portuguese variants', () => {
@@ -50,7 +58,9 @@ describe('getSentFolderCandidatesByRegex', () => {
const input = makeList(portugueseVariants);
const result = getImapSentFolderCandidatesByRegex(input);
expect(result).toEqual(expect.arrayContaining(portugueseVariants));
expect(result.map((r) => r.path)).toEqual(
expect.arrayContaining(portugueseVariants),
);
});
it('matches Italian variants', () => {
@@ -58,7 +68,9 @@ describe('getSentFolderCandidatesByRegex', () => {
const input = makeList(italianVariants);
const result = getImapSentFolderCandidatesByRegex(input);
expect(result).toEqual(expect.arrayContaining(italianVariants));
expect(result.map((r) => r.path)).toEqual(
expect.arrayContaining(italianVariants),
);
});
it('matches Korean variant', () => {
@@ -66,7 +78,7 @@ describe('getSentFolderCandidatesByRegex', () => {
const input = makeList(koreanVariants);
const result = getImapSentFolderCandidatesByRegex(input);
expect(result).toEqual(koreanVariants);
expect(result.map((r) => r.path)).toEqual(koreanVariants);
});
it('matches Japanese variants', () => {
@@ -74,7 +86,9 @@ describe('getSentFolderCandidatesByRegex', () => {
const input = makeList(japaneseVariants);
const result = getImapSentFolderCandidatesByRegex(input);
expect(result).toEqual(expect.arrayContaining(japaneseVariants));
expect(result.map((r) => r.path)).toEqual(
expect.arrayContaining(japaneseVariants),
);
});
it('matches Polish variants', () => {
@@ -82,7 +96,9 @@ describe('getSentFolderCandidatesByRegex', () => {
const input = makeList(polishVariants);
const result = getImapSentFolderCandidatesByRegex(input);
expect(result).toEqual(expect.arrayContaining(polishVariants));
expect(result.map((r) => r.path)).toEqual(
expect.arrayContaining(polishVariants),
);
});
it('matches Russian variants', () => {
@@ -95,7 +111,9 @@ describe('getSentFolderCandidatesByRegex', () => {
const input = makeList(russianVariants);
const result = getImapSentFolderCandidatesByRegex(input);
expect(result).toEqual(expect.arrayContaining(russianVariants));
expect(result.map((r) => r.path)).toEqual(
expect.arrayContaining(russianVariants),
);
});
it('matches Gmail special folder', () => {
@@ -103,7 +121,9 @@ describe('getSentFolderCandidatesByRegex', () => {
const input = makeList(gmailVariants);
const result = getImapSentFolderCandidatesByRegex(input);
expect(result).toEqual(expect.arrayContaining(gmailVariants));
expect(result.map((r) => r.path)).toEqual(
expect.arrayContaining(gmailVariants),
);
});
it('does not match unrelated folders', () => {
@@ -5,7 +5,7 @@ import { getStandardFolderByRegex } from 'src/modules/messaging/message-import-m
export function getImapSentFolderCandidatesByRegex(
list: ListResponse[],
): string[] {
): { name: string; path: string }[] {
const regexCandidateFolders: string[] = [];
for (const folder of list) {
@@ -16,5 +16,12 @@ export function getImapSentFolderCandidatesByRegex(
}
}
return regexCandidateFolders;
return regexCandidateFolders.map((folderPath) => {
const folder = list.find((folder) => folder.path === folderPath);
return {
name: folder?.name ?? folderPath,
path: folderPath,
};
});
}