diff --git a/packages/twenty-front/src/modules/accounts/types/MessageFolder.ts b/packages/twenty-front/src/modules/accounts/types/MessageFolder.ts index 471ba6a006..f8fa66382d 100644 --- a/packages/twenty-front/src/modules/accounts/types/MessageFolder.ts +++ b/packages/twenty-front/src/modules/accounts/types/MessageFolder.ts @@ -5,5 +5,7 @@ export type MessageFolder = { isSentFolder: boolean; isSynced: boolean; messageChannelId: string; + parentFolderId: string | null; + externalId: string | null; __typename: 'MessageFolder'; }; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsAccountsMessageFoldersCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsAccountsMessageFoldersCard.tsx index ae5fe0506d..fe1d5a39f6 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsAccountsMessageFoldersCard.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsAccountsMessageFoldersCard.tsx @@ -5,7 +5,8 @@ import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graph import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { SettingsMessageFoldersEmptyStateCard } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersEmptyStateCard'; -import { SettingsMessageFoldersTableRow } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTableRow'; +import { SettingsMessageFoldersTreeItem } from '@/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem'; +import { computeMessageFolderTree } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree'; import { settingsAccountsSelectedMessageChannelState } from '@/settings/accounts/states/settingsAccountsSelectedMessageChannelState'; import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; import { Table } from '@/ui/layout/table/components/Table'; @@ -18,8 +19,14 @@ import { Label } from 'twenty-ui/display'; import { Checkbox, CheckboxSize } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; -const StyledTableRows = styled.div` - max-height: 300px; +const StyledTreeList = styled.ul` + list-style: none; + margin: 0; + padding: 0; +`; + +const StyledFoldersContainer = styled.div` + max-height: 400px; overflow-y: auto; padding-bottom: ${({ theme }) => theme.spacing(2)}; padding-top: ${({ theme }) => theme.spacing(2)}; @@ -87,6 +94,10 @@ export const SettingsAccountsMessageFoldersCard = () => { ); }, [messageFolders, search]); + const folderTreeNodes = useMemo(() => { + return computeMessageFolderTree(filteredMessageFolders); + }, [filteredMessageFolders]); + const allFoldersToggled = useMemo(() => { return filteredMessageFolders.every((folder) => folder.isSynced); }, [filteredMessageFolders]); @@ -142,15 +153,17 @@ export const SettingsAccountsMessageFoldersCard = () => { - - {filteredMessageFolders?.map((folder) => ( - handleToggleFolder(folder)} - /> - ))} - + + + {folderTreeNodes.map((rootFolder) => ( + + ))} + + ); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersTableRow.tsx b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersTableRow.tsx deleted file mode 100644 index f8fa74a8c7..0000000000 --- a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersTableRow.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { type MessageFolder } from '@/accounts/types/MessageFolder'; -import { SettingsAccountsMessageFolderIcon } from '@/settings/accounts/components/message-folders/SettingsAccountsMessageFolderIcon'; -import { formatFolderName } from '@/settings/accounts/components/message-folders/utils/formatFolderName.util'; - -import { TableCell } from '@/ui/layout/table/components/TableCell'; -import { TableRow } from '@/ui/layout/table/components/TableRow'; -import styled from '@emotion/styled'; -import { Checkbox, CheckboxSize } from 'twenty-ui/input'; - -const StyledFolderNameCell = styled.div` - align-items: center; - display: flex; - gap: ${({ theme }) => theme.spacing(1)}; - color: ${({ theme }) => theme.font.color.primary}; -`; - -const StyledTableRow = styled(TableRow)``; - -const StyledCheckboxCell = styled(TableCell)` - display: flex; - justify-content: flex-end; -`; - -type SettingsMessageFoldersTableRowProps = { - folder: MessageFolder; - onSyncToggle: () => void; -}; - -export const SettingsMessageFoldersTableRow = ({ - folder, - onSyncToggle, -}: SettingsMessageFoldersTableRowProps) => { - return ( - - - - - {formatFolderName(folder.name)} - - - - - - - ); -}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem.tsx b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem.tsx new file mode 100644 index 0000000000..52e76dea07 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/SettingsMessageFoldersTreeItem.tsx @@ -0,0 +1,166 @@ +import { type MessageFolder } from '@/accounts/types/MessageFolder'; +import { SettingsAccountsMessageFolderIcon } from '@/settings/accounts/components/message-folders/SettingsAccountsMessageFolderIcon'; + +import { type MessageFolderTreeNode } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree'; +import { formatFolderName } from '@/settings/accounts/components/message-folders/utils/formatFolderName'; +import styled from '@emotion/styled'; +import { useState } from 'react'; +import { IconChevronRight } from 'twenty-ui/display'; +import { Checkbox, CheckboxSize } from 'twenty-ui/input'; + +type SettingsMessageFoldersTreeItemProps = { + folderTreeNode: MessageFolderTreeNode; + onToggleFolder: (folder: MessageFolder) => void; + depth?: number; +}; + +const StyledTreeItem = styled.li<{ hasChildren: boolean; depth: number }>` + position: relative; + margin-left: ${({ hasChildren, depth, theme }) => + !hasChildren && depth > 0 ? theme.spacing(3) : 0}; + + &:not(:last-child) { + margin-bottom: ${({ theme }) => theme.spacing(1)}; + } +`; + +const StyledNestedList = styled.ul` + border-left: 1px solid ${({ theme }) => theme.border.color.medium}; + list-style: none; + margin: ${({ theme }) => theme.spacing(1)} 0 0 + ${({ theme }) => theme.spacing(3)}; + padding: 0; + padding-left: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledTreeItemContent = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(1)}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + cursor: pointer; + transition: background-color + ${({ theme }) => theme.animation.duration.instant}s; + + &:hover { + background-color: ${({ theme }) => theme.background.transparent.lighter}; + } +`; + +const StyledExpandButton = styled.button<{ isExpanded: boolean }>` + background: none; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: ${({ theme }) => theme.spacing(4)}; + height: ${({ theme }) => theme.spacing(4)}; + color: ${({ theme }) => theme.font.color.tertiary}; + transition: transform ${({ theme }) => theme.animation.duration.instant}s; + transform: ${({ isExpanded }) => + isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'}; + + &:hover { + color: ${({ theme }) => theme.font.color.primary}; + } +`; + +const StyledFolderContent = styled.div` + align-items: center; + display: flex; + flex: 1; + gap: ${({ theme }) => theme.spacing(2)}; + justify-content: space-between; +`; + +const StyledFolderInfo = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(2)}; + flex: 1; + min-width: 0; +`; + +const StyledFolderName = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.md}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const StyledCheckboxWrapper = styled.div` + align-items: center; + display: flex; +`; + +export const SettingsMessageFoldersTreeItem = ({ + folderTreeNode, + onToggleFolder, + depth = 0, +}: SettingsMessageFoldersTreeItemProps) => { + const [isExpanded, setIsExpanded] = useState(true); + const { folder, children, hasChildren } = folderTreeNode; + + const handleExpandToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }; + + const handleRowClick = () => { + if (hasChildren) { + setIsExpanded(!isExpanded); + } + }; + + const handleCheckboxClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + return ( + + + {hasChildren && ( + + + + )} + + + + + {formatFolderName(folder.name)} + + + + onToggleFolder(folder)} + size={CheckboxSize.Small} + /> + + + + + {hasChildren && isExpanded && ( + + {children.map((child) => ( + + ))} + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/computeMessageFolderTree.test.ts b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/computeMessageFolderTree.test.ts new file mode 100644 index 0000000000..58af3f81be --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/__tests__/computeMessageFolderTree.test.ts @@ -0,0 +1,149 @@ +import { type MessageFolder } from '@/accounts/types/MessageFolder'; +import { computeMessageFolderTree } from '@/settings/accounts/components/message-folders/utils/computeMessageFolderTree'; + +describe('computeMessageFolderTree', () => { + const createFolder = ( + id: string, + name: string, + parentFolderId: string | null = null, + externalId: string | null = null, + ): MessageFolder => ({ + id, + name, + parentFolderId, + externalId: externalId || id, + isSentFolder: false, + isSynced: false, + messageChannelId: '20202020-1c25-4d02-bf25-6aeccf7ea419', + __typename: 'MessageFolder', + syncCursor: '', + }); + + it('should return empty array for empty input', () => { + expect(computeMessageFolderTree([])).toEqual([]); + }); + + it('should return single root folder without children', () => { + const folder = createFolder( + '20202020-7cf8-40bc-a681-b80b771449b1', + 'Inbox', + null, + ); + const result = computeMessageFolderTree([folder]); + + expect(result).toHaveLength(1); + expect(result[0].folder).toEqual(folder); + expect(result[0].hasChildren).toBe(false); + expect(result[0].children).toEqual([]); + }); + + it('should organize parent-child relationship', () => { + const parent = createFolder( + '20202020-7cf8-40bc-a681-b80b771449b2', + 'Work', + null, + 'ext-parent-id', + ); + const child = createFolder( + '20202020-7cf8-40bc-a681-b80b771449b3', + 'Projects', + 'ext-parent-id', + 'ext-child-id', + ); + const result = computeMessageFolderTree([parent, child]); + + expect(result).toHaveLength(1); + expect(result[0].folder.name).toBe('Work'); + expect(result[0].hasChildren).toBe(true); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].folder.name).toBe('Projects'); + }); + + it('should handle multiple levels of nesting', () => { + const grandparent = createFolder( + '20202020-7cf8-40bc-a681-b80b771449b4', + 'Work', + null, + 'ext-grandparent-id', + ); + const parent = createFolder( + '20202020-7cf8-40bc-a681-b80b771449b5', + 'Projects', + 'ext-grandparent-id', + 'ext-parent-id', + ); + const child = createFolder( + '20202020-7cf8-40bc-a681-b80b771449b6', + '2024', + 'ext-parent-id', + 'ext-child-id', + ); + const result = computeMessageFolderTree([grandparent, parent, child]); + + expect(result[0].folder.name).toBe('Work'); + expect(result[0].children[0].folder.name).toBe('Projects'); + expect(result[0].children[0].children[0].folder.name).toBe('2024'); + }); + + it('should sort root folders alphabetically', () => { + const folders = [ + createFolder('20202020-7cf8-40bc-a681-b80b771449b7', 'Sent', null), + createFolder('20202020-7cf8-40bc-a681-b80b771449b8', 'Inbox', null), + createFolder('20202020-7cf8-40bc-a681-b80b771449b9', 'Drafts', null), + ]; + const result = computeMessageFolderTree(folders); + + expect(result[0].folder.name).toBe('Drafts'); + expect(result[1].folder.name).toBe('Inbox'); + expect(result[2].folder.name).toBe('Sent'); + }); + + it('should sort children alphabetically', () => { + const parent = createFolder( + '20202020-7cf8-40bc-a681-b80b771449c0', + 'Work', + null, + 'ext-parent-id', + ); + const child1 = createFolder( + '20202020-7cf8-40bc-a681-b80b771449c1', + 'Zebra', + 'ext-parent-id', + 'ext-child1-id', + ); + const child2 = createFolder( + '20202020-7cf8-40bc-a681-b80b771449c2', + 'Apple', + 'ext-parent-id', + 'ext-child2-id', + ); + const result = computeMessageFolderTree([parent, child1, child2]); + + expect(result[0].children[0].folder.name).toBe('Apple'); + expect(result[0].children[1].folder.name).toBe('Zebra'); + }); + + it('should treat orphaned folders as root folders', () => { + const orphan = createFolder('o', 'Orphan', 'non-existent', 'ext-o'); + const normal = createFolder('n', 'Normal', null, 'ext-n'); + const result = computeMessageFolderTree([orphan, normal]); + + expect(result).toHaveLength(2); + expect(result.map((r) => r.folder.name).sort()).toEqual([ + 'Normal', + 'Orphan', + ]); + }); + + it('should handle Gmail-style nested labels', () => { + const work = createFolder('1', 'Work', null, 'ext-work'); + const projects = createFolder('2', 'Projects', 'ext-work', 'ext-proj'); + const clients = createFolder('3', 'Clients', 'ext-work', 'ext-cli'); + const result = computeMessageFolderTree([work, projects, clients]); + + expect(result).toHaveLength(1); + expect(result[0].children).toHaveLength(2); + expect(result[0].children[0].folder.name).toBe('Clients'); + expect(result[0].children[1].folder.name).toBe('Projects'); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/computeMessageFolderTree.ts b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/computeMessageFolderTree.ts new file mode 100644 index 0000000000..9d4d291d29 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/computeMessageFolderTree.ts @@ -0,0 +1,55 @@ +import { type MessageFolder } from '@/accounts/types/MessageFolder'; +import { isDefined } from 'twenty-shared/utils'; + +export type MessageFolderTreeNode = { + folder: MessageFolder; + children: MessageFolderTreeNode[]; + hasChildren: boolean; +}; + +export const computeMessageFolderTree = ( + folders: MessageFolder[], +): MessageFolderTreeNode[] => { + const folderByExternalIdMap = new Map(); + const childrenMap = new Map(); + + folders.forEach((folder) => { + if (isDefined(folder.externalId)) { + folderByExternalIdMap.set(folder.externalId, folder); + } + }); + + folders.forEach((folder) => { + if (isDefined(folder.parentFolderId)) { + const parent = folderByExternalIdMap.get(folder.parentFolderId); + if (isDefined(parent)) { + const siblings = childrenMap.get(parent.id) || []; + siblings.push(folder); + childrenMap.set(parent.id, siblings); + } + } + }); + + const buildTreeNode = (folder: MessageFolder): MessageFolderTreeNode => { + const children = childrenMap.get(folder.id) || []; + const sortedChildren = [...children].sort((a, b) => + a.name.localeCompare(b.name), + ); + + return { + folder, + children: sortedChildren.map((child) => buildTreeNode(child)), + hasChildren: children.length > 0, + }; + }; + + const rootFolders = folders.filter((folder) => { + if (!folder.parentFolderId) return true; + + return !folderByExternalIdMap.has(folder.parentFolderId); + }); + + rootFolders.sort((a, b) => a.name.localeCompare(b.name)); + + return rootFolders.map((folder) => buildTreeNode(folder)); +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/formatFolderName.util.ts b/packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/formatFolderName.ts similarity index 100% rename from packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/formatFolderName.util.ts rename to packages/twenty-front/src/modules/settings/accounts/components/message-folders/utils/formatFolderName.ts diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index 9ba624f8c6..9d9b27078d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -260,6 +260,7 @@ export const MESSAGE_CHANNEL_STANDARD_FIELD_IDS = { export const MESSAGE_FOLDER_STANDARD_FIELD_IDS = { name: '20202020-7cf8-40bc-a681-b80b771449b7', + parentFolderId: '20202020-e45d-49de-a4aa-587bbf9601f3', messageChannel: '20202020-b658-408f-bd46-3bd2d15d7e52', syncCursor: '20202020-98cd-49ed-8dfc-cb5796400e64', isSentFolder: '20202020-2af5-4a25-b2de-3c9386da941b', diff --git a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-folder.workspace-entity.ts b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-folder.workspace-entity.ts index e8ab95b10d..6fa4c6c88f 100644 --- a/packages/twenty-server/src/modules/messaging/common/standard-objects/message-folder.workspace-entity.ts +++ b/packages/twenty-server/src/modules/messaging/common/standard-objects/message-folder.workspace-entity.ts @@ -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, diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/gmail-get-all-folders.service.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/gmail-get-all-folders.service.ts index 9ab942b72d..bd2bdb35a8 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/gmail-get-all-folders.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/gmail-get-all-folders.service.ts @@ -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(); + + 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, }); } diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/utils/__tests__/extract-gmail-folder-name.util.spec.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/utils/__tests__/extract-gmail-folder-name.util.spec.ts new file mode 100644 index 0000000000..0eb75fb3be --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/utils/__tests__/extract-gmail-folder-name.util.spec.ts @@ -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', + ); + }); +}); diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/utils/__tests__/get-gmail-folder-parent-id.util.spec.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/utils/__tests__/get-gmail-folder-parent-id.util.spec.ts new file mode 100644 index 0000000000..40902a2c40 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/utils/__tests__/get-gmail-folder-parent-id.util.spec.ts @@ -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([ + ['Inbox', 'INBOX'], + ['Sent', 'SENT'], + ]); + + expect(getGmailFolderParentId('Inbox', labelNameToIdMap)).toBeNull(); + }); + + it('should return parent ID for nested folder', () => { + const labelNameToIdMap = new Map([ + ['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([ + ['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([ + ['Work/Projects', 'projects-id'], + ]); + + expect( + getGmailFolderParentId('Work/Projects', labelNameToIdMap), + ).toBeNull(); + }); + + it('should handle Gmail-style nested labels', () => { + const labelNameToIdMap = new Map([ + ['[Gmail]', 'gmail-id'], + ['[Gmail]/Sent Mail', 'sent-id'], + ]); + + expect(getGmailFolderParentId('[Gmail]/Sent Mail', labelNameToIdMap)).toBe( + 'gmail-id', + ); + }); +}); diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/utils/extract-gmail-folder-name.util.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/utils/extract-gmail-folder-name.util.ts new file mode 100644 index 0000000000..2f24f6a6e3 --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/utils/extract-gmail-folder-name.util.ts @@ -0,0 +1,7 @@ +export const extractGmailFolderName = (labelName: string): string => { + if (!labelName.includes('/')) { + return labelName; + } + + return labelName.substring(labelName.lastIndexOf('/') + 1); +}; diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/utils/get-gmail-folder-parent-id.util.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/utils/get-gmail-folder-parent-id.util.ts new file mode 100644 index 0000000000..7c7002724b --- /dev/null +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/gmail/utils/get-gmail-folder-parent-id.util.ts @@ -0,0 +1,11 @@ +export const getGmailFolderParentId = ( + labelName: string, + labelNameToIdMap: Map, +): string | null => { + if (!labelName.includes('/')) { + return null; + } + const parentName = labelName.substring(0, labelName.lastIndexOf('/')); + + return labelNameToIdMap.get(parentName) || null; +}; diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/imap-get-all-folders.service.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/imap-get-all-folders.service.ts index d4081e6fc7..3fe5df7679 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/imap-get-all-folders.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/imap/imap-get-all-folders.service.ts @@ -55,47 +55,64 @@ export class ImapGetAllFoldersService implements MessageFolderDriver { mailboxList: ListResponse[], ): Promise { const folders: MessageFolder[] = []; - const sentFolderPath = + const pathToExternalIdMap = new Map(); + 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; } diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/microsoft/microsoft-get-all-folders.service.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/microsoft/microsoft-get-all-folders.service.ts index dd23c8fff7..04066a332d 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/microsoft/microsoft-get-all-folders.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/drivers/microsoft/microsoft-get-all-folders.service.ts @@ -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; + } } diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface.ts index 78d7aed8eb..7ac6f31953 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface.ts @@ -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 { diff --git a/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts b/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts index 56451e4612..b79acce18d 100644 --- a/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-folder-manager/services/sync-message-folders.service.ts @@ -32,10 +32,14 @@ type MessageFolderToInsert = Pick< | 'isSynced' | 'isSentFolder' | 'externalId' + | 'parentFolderId' >; type MessageFolderToUpdate = Partial< - Pick + 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, }); } diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service.ts index 2d91cdfc34..91711af64a 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-find-sent-folder.service.ts @@ -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 { + public async findSentFolder(client: ImapFlow): Promise { try { const list = await client.list(); @@ -60,7 +65,7 @@ export class ImapFindSentFolderService { private async findSentFolderBySpecialUse( client: ImapFlow, list: ListResponse[], - ): Promise { + ): Promise { 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 { + ): Promise { 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; diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service.ts index ae1b84990e..1d00e769dd 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/services/imap-get-message-list.service.ts @@ -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, ); diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/utils/__tests__/get-sent-folder-candidates-by-regex.util.spec.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/utils/__tests__/get-sent-folder-candidates-by-regex.util.spec.ts index 862d901483..5334507c66 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/utils/__tests__/get-sent-folder-candidates-by-regex.util.spec.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/utils/__tests__/get-sent-folder-candidates-by-regex.util.spec.ts @@ -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', () => { diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/utils/get-sent-folder-candidates-by-regex.util.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/utils/get-sent-folder-candidates-by-regex.util.ts index 3bbd05f63b..142330cffc 100644 --- a/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/utils/get-sent-folder-candidates-by-regex.util.ts +++ b/packages/twenty-server/src/modules/messaging/message-import-manager/drivers/imap/utils/get-sent-folder-candidates-by-regex.util.ts @@ -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, + }; + }); }