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
@@ -5,5 +5,7 @@ export type MessageFolder = {
isSentFolder: boolean;
isSynced: boolean;
messageChannelId: string;
parentFolderId: string | null;
externalId: string | null;
__typename: 'MessageFolder';
};
@@ -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 = () => {
</StyledCheckboxCell>
</StyledSectionHeader>
<StyledTableRows>
{filteredMessageFolders?.map((folder) => (
<SettingsMessageFoldersTableRow
key={folder.id}
folder={folder}
onSyncToggle={() => handleToggleFolder(folder)}
/>
))}
</StyledTableRows>
<StyledFoldersContainer>
<StyledTreeList>
{folderTreeNodes.map((rootFolder) => (
<SettingsMessageFoldersTreeItem
key={rootFolder.folder.id}
folderTreeNode={rootFolder}
onToggleFolder={handleToggleFolder}
/>
))}
</StyledTreeList>
</StyledFoldersContainer>
</Table>
</Section>
);
@@ -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 (
<StyledTableRow gridAutoColumns="1fr 120px">
<TableCell>
<StyledFolderNameCell>
<SettingsAccountsMessageFolderIcon folder={folder} />
{formatFolderName(folder.name)}
</StyledFolderNameCell>
</TableCell>
<StyledCheckboxCell>
<Checkbox
checked={folder.isSynced}
onChange={onSyncToggle}
size={CheckboxSize.Small}
/>
</StyledCheckboxCell>
</StyledTableRow>
);
};
@@ -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 (
<StyledTreeItem hasChildren={hasChildren} depth={depth}>
<StyledTreeItemContent onClick={handleRowClick}>
{hasChildren && (
<StyledExpandButton
isExpanded={isExpanded}
onClick={handleExpandToggle}
aria-label={isExpanded ? 'Collapse folder' : 'Expand folder'}
>
<IconChevronRight size={16} />
</StyledExpandButton>
)}
<StyledFolderContent>
<StyledFolderInfo>
<SettingsAccountsMessageFolderIcon folder={folder} />
<StyledFolderName>{formatFolderName(folder.name)}</StyledFolderName>
</StyledFolderInfo>
<StyledCheckboxWrapper onClick={handleCheckboxClick}>
<Checkbox
checked={folder.isSynced}
onChange={() => onToggleFolder(folder)}
size={CheckboxSize.Small}
/>
</StyledCheckboxWrapper>
</StyledFolderContent>
</StyledTreeItemContent>
{hasChildren && isExpanded && (
<StyledNestedList>
{children.map((child) => (
<SettingsMessageFoldersTreeItem
key={child.folder.id}
folderTreeNode={child}
onToggleFolder={onToggleFolder}
depth={depth + 1}
/>
))}
</StyledNestedList>
)}
</StyledTreeItem>
);
};
@@ -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');
});
});
@@ -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<string, MessageFolder>();
const childrenMap = new Map<string, MessageFolder[]>();
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));
};
@@ -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',
@@ -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,
};
});
}