Child folders followup (#15526)
This commit is contained in:
@@ -5,5 +5,7 @@ export type MessageFolder = {
|
||||
isSentFolder: boolean;
|
||||
isSynced: boolean;
|
||||
messageChannelId: string;
|
||||
parentFolderId: string | null;
|
||||
externalId: string | null;
|
||||
__typename: 'MessageFolder';
|
||||
};
|
||||
|
||||
+25
-12
@@ -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>
|
||||
);
|
||||
|
||||
-50
@@ -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>
|
||||
);
|
||||
};
|
||||
+166
@@ -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>
|
||||
);
|
||||
};
|
||||
+149
@@ -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');
|
||||
});
|
||||
});
|
||||
+55
@@ -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));
|
||||
};
|
||||
+1
@@ -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',
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
+19
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+56
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export const extractGmailFolderName = (labelName: string): string => {
|
||||
if (!labelName.includes('/')) {
|
||||
return labelName;
|
||||
}
|
||||
|
||||
return labelName.substring(labelName.lastIndexOf('/') + 1);
|
||||
};
|
||||
+11
@@ -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;
|
||||
};
|
||||
+53
-30
@@ -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;
|
||||
}
|
||||
|
||||
+64
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+7
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+30
-9
@@ -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;
|
||||
|
||||
+8
-1
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+32
-12
@@ -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', () => {
|
||||
|
||||
+9
-2
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user