From 42b093a243a0dcbc45eaf0ecd0e7e4d051d6ef4b Mon Sep 17 00:00:00 2001 From: Bernhard Wittmann Date: Tue, 21 Apr 2026 09:20:02 +0200 Subject: [PATCH] fix(Microsoft Outlook Trigger Node): Show nested subfolders in folder dropdowns Paginate child folder requests with microsoftApiRequestAllItems so folders with many children are returned in full, and prefix subfolder display names with their parent path so they are distinguishable from top-level folders in the picker. --- .../test/v2/methods/listSearch.test.ts | 2 +- .../test/v2/methods/loadOptions.test.ts | 2 +- .../Outlook/test/v2/transport/index.test.ts | 94 +++++++++++++++++++ .../Outlook/v2/methods/listSearch.ts | 2 +- .../Outlook/v2/methods/loadOptions.ts | 2 +- .../Microsoft/Outlook/v2/transport/index.ts | 7 +- 6 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 packages/nodes-base/nodes/Microsoft/Outlook/test/v2/transport/index.test.ts diff --git a/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/methods/listSearch.test.ts b/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/methods/listSearch.test.ts index dd724e006496c..92e4774eff52a 100644 --- a/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/methods/listSearch.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/methods/listSearch.test.ts @@ -405,7 +405,7 @@ describe('MicrosoftOutlookV2 - listSearch methods', () => { $top: 100, }, ); - expect(mockTransport.getSubfolders).toHaveBeenCalledWith(mockResponse.value); + expect(mockTransport.getSubfolders).toHaveBeenCalledWith(mockResponse.value, true); expect(result).toEqual({ results: [ { diff --git a/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/methods/loadOptions.test.ts b/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/methods/loadOptions.test.ts index 48656418ddb82..e0544aeba1c53 100644 --- a/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/methods/loadOptions.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/methods/loadOptions.test.ts @@ -126,7 +126,7 @@ describe('MicrosoftOutlookV2 - loadOptions methods', () => { '/mailFolders', {}, ); - expect(mockTransport.getSubfolders).toHaveBeenCalledWith(mockResponse); + expect(mockTransport.getSubfolders).toHaveBeenCalledWith(mockResponse, true); expect(result).toEqual([ { name: 'Inbox', value: 'folder1' }, { name: 'Sent Items', value: 'folder2' }, diff --git a/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/transport/index.test.ts b/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/transport/index.test.ts new file mode 100644 index 0000000000000..b9382aba2ec62 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Outlook/test/v2/transport/index.test.ts @@ -0,0 +1,94 @@ +import { mockDeep } from 'jest-mock-extended'; +import type { ILoadOptionsFunctions } from 'n8n-workflow'; + +import { getSubfolders } from '../../../v2/transport'; + +describe('MicrosoftOutlookV2 - getSubfolders', () => { + let mockLoadOptionsFunctions: jest.Mocked; + + beforeEach(() => { + mockLoadOptionsFunctions = mockDeep(); + mockLoadOptionsFunctions.getCredentials.mockResolvedValue({}); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should not request childFolders when childFolderCount is 0', async () => { + const folders = [ + { id: 'folder1', displayName: 'Inbox', childFolderCount: 0 }, + { id: 'folder2', displayName: 'Sent Items', childFolderCount: 0 }, + ]; + + const result = await getSubfolders.call(mockLoadOptionsFunctions, folders); + + expect( + mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock, + ).not.toHaveBeenCalled(); + expect(result).toEqual(folders); + }); + + it('should paginate child folder requests using nextLink', async () => { + const folders = [{ id: 'inbox', displayName: 'Inbox', childFolderCount: 2 }]; + + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock) + .mockResolvedValueOnce({ + value: [{ id: 'sub1', displayName: 'Work', childFolderCount: 0 }], + '@odata.nextLink': + 'https://graph.microsoft.com/v1.0/me/mailFolders/inbox/childFolders?$skip=1', + }) + .mockResolvedValueOnce({ + value: [{ id: 'sub2', displayName: 'Projects', childFolderCount: 0 }], + }); + + const result = await getSubfolders.call(mockLoadOptionsFunctions, folders); + + expect( + mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock, + ).toHaveBeenCalledTimes(2); + expect(result).toEqual([ + { id: 'inbox', displayName: 'Inbox', childFolderCount: 2 }, + { id: 'sub1', displayName: 'Work', childFolderCount: 0 }, + { id: 'sub2', displayName: 'Projects', childFolderCount: 0 }, + ]); + }); + + it('should prefix nested subfolder displayNames with full parent path', async () => { + const folders = [{ id: 'inbox', displayName: 'Inbox', childFolderCount: 1 }]; + + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock) + .mockResolvedValueOnce({ + value: [{ id: 'work', displayName: 'Work', childFolderCount: 1 }], + }) + .mockResolvedValueOnce({ + value: [{ id: 'q2', displayName: 'Q2', childFolderCount: 0 }], + }); + + const result = await getSubfolders.call(mockLoadOptionsFunctions, folders, true); + + expect(result).toEqual([ + { id: 'inbox', displayName: 'Inbox', childFolderCount: 1 }, + { id: 'work', displayName: 'Inbox/Work', childFolderCount: 1 }, + { id: 'q2', displayName: 'Inbox/Work/Q2', childFolderCount: 0 }, + ]); + }); + + it('should return bare subfolder displayNames when addPathToDisplayName is false', async () => { + const folders = [{ id: 'inbox', displayName: 'Inbox', childFolderCount: 1 }]; + + (mockLoadOptionsFunctions.helpers.requestWithAuthentication as jest.Mock).mockResolvedValueOnce( + { + value: [{ id: 'work', displayName: 'Work', childFolderCount: 0 }], + }, + ); + + const result = await getSubfolders.call(mockLoadOptionsFunctions, folders); + + expect(result).toEqual([ + { id: 'inbox', displayName: 'Inbox', childFolderCount: 1 }, + { id: 'work', displayName: 'Work', childFolderCount: 0 }, + ]); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Outlook/v2/methods/listSearch.ts b/packages/nodes-base/nodes/Microsoft/Outlook/v2/methods/listSearch.ts index ecdd99bf370a3..c50c91d7f85a8 100644 --- a/packages/nodes-base/nodes/Microsoft/Outlook/v2/methods/listSearch.ts +++ b/packages/nodes-base/nodes/Microsoft/Outlook/v2/methods/listSearch.ts @@ -223,7 +223,7 @@ export async function searchFolders( response = await microsoftApiRequest.call(this, 'GET', '/mailFolders', undefined, qs); } - let folders = await getSubfolders.call(this, response.value as IDataObject[]); + let folders = await getSubfolders.call(this, response.value as IDataObject[], true); if (filter) { filter = filter.toLowerCase(); diff --git a/packages/nodes-base/nodes/Microsoft/Outlook/v2/methods/loadOptions.ts b/packages/nodes-base/nodes/Microsoft/Outlook/v2/methods/loadOptions.ts index 7e32e8ba0a24b..9896ada367518 100644 --- a/packages/nodes-base/nodes/Microsoft/Outlook/v2/methods/loadOptions.ts +++ b/packages/nodes-base/nodes/Microsoft/Outlook/v2/methods/loadOptions.ts @@ -24,7 +24,7 @@ export async function getCategoriesNames( export async function getFolders(this: ILoadOptionsFunctions): Promise { const returnData: INodePropertyOptions[] = []; const response = await microsoftApiRequestAllItems.call(this, 'value', 'GET', '/mailFolders', {}); - const folders = await getSubfolders.call(this, response); + const folders = await getSubfolders.call(this, response, true); for (const folder of folders) { returnData.push({ name: folder.displayName as string, diff --git a/packages/nodes-base/nodes/Microsoft/Outlook/v2/transport/index.ts b/packages/nodes-base/nodes/Microsoft/Outlook/v2/transport/index.ts index 4ea65527dbeb1..8ada9701332d7 100644 --- a/packages/nodes-base/nodes/Microsoft/Outlook/v2/transport/index.ts +++ b/packages/nodes-base/nodes/Microsoft/Outlook/v2/transport/index.ts @@ -206,21 +206,20 @@ export async function getSubfolders( const returnData: IDataObject[] = [...folders]; for (const folder of folders) { if ((folder.childFolderCount as number) > 0) { - let subfolders = await microsoftApiRequest.call( + let subfolders = await microsoftApiRequestAllItems.call( this, + 'value', 'GET', `/mailFolders/${folder.id}/childFolders`, ); if (addPathToDisplayName) { - subfolders = subfolders.value.map((subfolder: IDataObject) => { + subfolders = subfolders.map((subfolder: IDataObject) => { return { ...subfolder, displayName: `${folder.displayName}/${subfolder.displayName}`, }; }); - } else { - subfolders = subfolders.value; } returnData.push(