diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 068e9d96bcb..21ba061d090 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -43,6 +43,7 @@ const dictionary = [ 'eventreceiver', 'external', 'externalize', + 'folder', 'fun', 'group', 'groupify', @@ -100,6 +101,7 @@ const dictionary = [ 'role', 'room', 'schema', + 'search', 'sensitivity', 'service', 'session', diff --git a/docs/docs/cmd/outlook/mail/mail-searchfolder-add.mdx b/docs/docs/cmd/outlook/mail/mail-searchfolder-add.mdx new file mode 100644 index 00000000000..3b6a4acd94b --- /dev/null +++ b/docs/docs/cmd/outlook/mail/mail-searchfolder-add.mdx @@ -0,0 +1,128 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# outlook mail searchfolder add + +Creates a new mail search folder in the user's mailbox + +## Usage + +```sh +m365 outlook mail searchfolder add [options] +``` + +## Options + +```md definition-list +`-i, --userId [userId]` +: The id of the user in whose mailbox the search folder should be created. Specify either `userId` or `userName`, but not both. + +`-n, --userName [userName]` +: The UPN of the user in whose mailbox the search folder should be created. Specify either `userId` or `userName`, but not both. + +`--folderName ` +: The name of the mail search folder. + +`--sourceFolderIds ` +: Comma-separated list of mail folders that should be searched. + +`--includeNestedFolders` +: The nested mail folders will be searched if specified. + +`--messageFilter ` +: The OData query to filter the messages. +``` + + + +## Examples + +Create a mail search folder in the user's mailbox specified by id for messages from the inbox that contain specific subject + +```sh +m365 outlook mail searchfolder add --userId 1caf7dcd-7e83-4c3a-94f7-932a1299c844 --folderName 'CLI m365' --sourceFolderIds 'AQMkADYAAAIBDAAAAA==' --messageFilter "contains(subject, 'CLI for Microsoft 365')" +``` + +Create a mail search folder in the user's mailbox specified by UPN for incoming and outgoing messages from a specific year that contain specific text in a message body, search for messages inside all subfolders + +```sh +m365 outlook mail searchfolder add --userName john.doe@contoso.com --folderName 'Power Platform Community' --sourceFolderIds 'AQMkADYAAAIBDAAAAA==,AQMkADYAAAIBDBBBBB==' --includeNestedFolders --messageFilter "contains(body/content,'Power Platform') AND receivedDateTime ge 2024-01-01 AND receivedDateTime le 2024-12-31" +``` + +## Response + + + + + ```json + { + "id": "AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YHqjbDnRgQABco84sgAAAA==", + "displayName": "Microsoft Entra", + "parentFolderId": "AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YHqjbDnRgQAAAgEEAAAA", + "childFolderCount": 0, + "unreadItemCount": 27, + "totalItemCount": 41, + "sizeInBytes": null, + "isHidden": false, + "isSupported": true, + "includeNestedFolders": false, + "sourceFolderIds": [ + "AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YHqjbDnRgQAAAgEMAAAA" + ], + "filterQuery": "contains(subject,'Microsoft Entra ID')" + } + ``` + + + + + ```text + childFolderCount : 0 + displayName : Microsoft Entra + filterQuery : contains(subject,'Microsoft Entra ID') + id : AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YHqjbDnRgQABco88nAAAAA== + includeNestedFolders: false + isHidden : false + isSupported : true + parentFolderId : AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YHqjbDnRgQAAAgEEAAAA + sizeInBytes : null + sourceFolderIds : ["AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YHqjbDnRgQAAAgEMAAAA"] + totalItemCount : 41 + unreadItemCount : 27 + ``` + + + + + ```csv + id,displayName,parentFolderId,childFolderCount,unreadItemCount,totalItemCount,sizeInBytes,isHidden,isSupported,includeNestedFolders,filterQuery + AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YHqjbDnRgQABco88ngAAAA==,Microsoft Entra,AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YHqjbDnRgQAAAgEEAAAA,0,27,41,,,1,,"contains(subject,'Microsoft Entra ID')" + ``` + + + + + ```md + # outlook mail searchfolder add --debug "false" --verbose "false" --userName "john.doe@contoso.com" --folderName "Microsoft Entra4" --messageFilter "contains(subject,'Microsoft Entra ID')" --sourceFoldersIds "AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YHqjbDnRgQAAAgEMAAAA" + + Date: 9/6/2024 + + ## Microsoft Entra (AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YHqjbDnRgQABco88oAAAAA==) + + Property | Value + ---------|------- + id | AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YHqjbDnRgQABco88oAAAAA== + displayName | Microsoft Entra + parentFolderId | AQMkAGRlM2Y5YTkzLWI2NzAtNDczOS05YHqjbDnRgQAAAgEEAAAA + childFolderCount | 0 + unreadItemCount | 27 + totalItemCount | 41 + isHidden | false + isSupported | true + includeNestedFolders | false + filterQuery | contains(subject,'Microsoft Entra ID') + ``` + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 6ff7427cf3b..86d5afef287 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -1227,6 +1227,11 @@ const sidebars: SidebarsConfig = { 'Outlook (outlook)': [ { mail: [ + { + type: 'doc', + label: 'mail searchfolder add', + id: 'cmd/outlook/mail/mail-searchfolder-add' + }, { type: 'doc', label: 'mail send', diff --git a/src/m365/outlook/commands.ts b/src/m365/outlook/commands.ts index 1600259124e..e1cb51b0e07 100644 --- a/src/m365/outlook/commands.ts +++ b/src/m365/outlook/commands.ts @@ -1,6 +1,7 @@ const prefix: string = 'outlook'; export default { + MAIL_SEARCHFOLDER_ADD: `${prefix} mail searchfolder add`, MAIL_SEND: `${prefix} mail send`, MAILBOX_SETTINGS_GET: `${prefix} mailbox settings get`, MAILBOX_SETTINGS_SET: `${prefix} mailbox settings set`, diff --git a/src/m365/outlook/commands/mail/mail-searchfolder-add.spec.ts b/src/m365/outlook/commands/mail/mail-searchfolder-add.spec.ts new file mode 100644 index 00000000000..d704843ebba --- /dev/null +++ b/src/m365/outlook/commands/mail/mail-searchfolder-add.spec.ts @@ -0,0 +1,303 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import { z } from 'zod'; +import auth from '../../../../Auth.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import commands from '../../commands.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import command from './mail-searchfolder-add.js'; +import { cli } from '../../../../cli/cli.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import request from '../../../../request.js'; +import { CommandError } from '../../../../Command.js'; +import { accessToken } from '../../../../utils/accessToken.js'; + +describe(commands.MAIL_SEARCHFOLDER_ADD, () => { + const userId = 'ae0e8388-cd70-427f-9503-c57498ee3337'; + const userName = 'john.doe@contoso.com'; + const sourceFolderId1 = 'AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQA7DGC6yx9ARZqQFWs3P3q1AAAASBOHAAA='; + const sourceFolderId2 = 'AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQA7DGC6yx9ARZqQFWs3P3q1AAAASBOHAAB='; + const filterQuery = `subject eq 'Contoso'`; + const response = { + id: "AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQACAd7HWUedTo-i2ZIVhDiHAAoGOwIyAAA=", + displayName: "Contoso", + parentFolderId: "AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQA7DGC6yx9ARZqQFWs3P3q1AAAASBOLAAA=", + childFolderCount: 0, + unreadItemCount: 0, + totalItemCount: 5, + sizeInBytes: null, + isHidden: false, + isSupported: true, + includeNestedFolders: false, + sourceFolderIds: [ + sourceFolderId1 + ], + filterQuery: filterQuery + }; + const responseWithNestedFolders = { + id: "AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQACAd7HWUedTo-i2ZIVhDiHAAoGOwIyAAA=", + displayName: "Contoso", + parentFolderId: "AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQA7DGC6yx9ARZqQFWs3P3q1AAAASBOLAAA=", + childFolderCount: 0, + unreadItemCount: 0, + totalItemCount: 5, + sizeInBytes: null, + isHidden: false, + isSupported: true, + includeNestedFolders: true, + sourceFolderIds: [ + sourceFolderId1, + sourceFolderId2 + ], + filterQuery: filterQuery + }; + + let log: any[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').resolves(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + if (!auth.connection.accessTokens[auth.defaultResource]) { + auth.connection.accessTokens[auth.defaultResource] = { + expiresOn: 'abc', + accessToken: 'abc' + }; + } + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(false); + }); + + afterEach(() => { + sinonUtil.restore([ + accessToken.isAppOnlyAccessToken, + request.post + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.MAIL_SEARCHFOLDER_ADD); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if userId is not a valid GUID', () => { + const actual = commandOptionsSchema.safeParse({ + userId: 'foo', + folderName: 'Contoso', + messageFilter: filterQuery, + sourceFoldersIds: sourceFolderId1 + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if userName is not a valid user principal name', () => { + const actual = commandOptionsSchema.safeParse({ + userName: 'foo', + folderName: 'Contoso', + messageFilter: filterQuery, + sourceFoldersIds: sourceFolderId1 + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if both userId and userName is specified', () => { + const actual = commandOptionsSchema.safeParse({ + userId: userId, + userName: userName, + folderName: 'Contoso', + messageFilter: filterQuery, + sourceFoldersIds: sourceFolderId1 + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if folderName is not specified', () => { + const actual = commandOptionsSchema.safeParse({ + userId: userId, + messageFilter: filterQuery, + sourceFoldersIds: sourceFolderId1 + }); + assert.notStrictEqual(actual.success, true); + }); + + it('fails validation if messageFilter is not specified', () => { + const actual = commandOptionsSchema.safeParse({ + userId: userId, + folderName: 'Contoso', + sourceFoldersIds: sourceFolderId1 + }); + assert.notStrictEqual(actual.success, true); + }); + + it('correctly creates a mail search folder in the mailbox of the signed-in user', async () => { + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === 'https://graph.microsoft.com/v1.0/me/mailFolders/searchFolders/childFolders') { + return response; + } + + throw 'Invalid request'; + }); + + const parsedSchema = commandOptionsSchema.safeParse({ + folderName: 'Contoso', + messageFilter: filterQuery, + sourceFoldersIds: sourceFolderId1 + }); + await command.action(logger, { options: parsedSchema.data }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('correctly creates a mail search folder in the mailbox of a user specified by id', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userId}')/mailFolders/searchFolders/childFolders`) { + return response; + } + + throw 'Invalid request'; + }); + + const parsedSchema = commandOptionsSchema.safeParse({ + userId: userId, + folderName: 'Contoso', + messageFilter: filterQuery, + sourceFoldersIds: sourceFolderId1 + }); + await command.action(logger, { options: parsedSchema.data }); + assert(loggerLogSpy.calledOnceWithExactly(response)); + }); + + it('correctly creates a mail search folder in the mailbox of a user specified by UPN', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `https://graph.microsoft.com/v1.0/users('${userName}')/mailFolders/searchFolders/childFolders`) { + return responseWithNestedFolders; + } + + throw 'Invalid request'; + }); + + const parsedSchema = commandOptionsSchema.safeParse({ + userName: userName, + folderName: 'Contoso', + messageFilter: filterQuery, + sourceFoldersIds: `${sourceFolderId1},${sourceFolderId2}`, + includeNestedFolders: true, + verbose: true + }); + await command.action(logger, { options: parsedSchema.data }); + assert(loggerLogSpy.calledOnceWithExactly(responseWithNestedFolders)); + }); + + it('fails creating a mail search folder if neither userId nor userName is specified in app-only mode', async () => { + sinonUtil.restore(accessToken.isAppOnlyAccessToken); + sinon.stub(accessToken, 'isAppOnlyAccessToken').returns(true); + + const parsedSchema = commandOptionsSchema.safeParse({ + folderName: 'Contoso', + messageFilter: filterQuery, + sourceFoldersIds: `${sourceFolderId1},${sourceFolderId2}`, + includeNestedFolders: true, + verbose: true + }); + await assert.rejects(command.action(logger, { options: parsedSchema.data }), new CommandError('When running with application permissions either userId or userName is required')); + }); + + it('fails creating a mail search folder for signed-in user if userId is specified', async () => { + const parsedSchema = commandOptionsSchema.safeParse({ + userId: userId, + folderName: 'Contoso', + messageFilter: filterQuery, + sourceFoldersIds: `${sourceFolderId1},${sourceFolderId2}`, + includeNestedFolders: true, + verbose: true + }); + await assert.rejects(command.action(logger, { options: parsedSchema.data }), new CommandError('You can create mail search folder for other users only if CLI is authenticated in app-only mode')); + }); + + it('fails creating a mail search folder for signed-in user if userName is specified', async () => { + const parsedSchema = commandOptionsSchema.safeParse({ + userName: userName, + folderName: 'Contoso', + messageFilter: filterQuery, + sourceFoldersIds: `${sourceFolderId1},${sourceFolderId2}`, + includeNestedFolders: true, + verbose: true + }); + await assert.rejects(command.action(logger, { options: parsedSchema.data }), new CommandError('You can create mail search folder for other users only if CLI is authenticated in app-only mode')); + }); + + it('correctly handles error when invalid folder id is specified', async () => { + sinon.stub(request, 'post').rejects({ + error: { + error: { + code: 'ErrorInvalidIdMalformed', + message: 'Id is malformed.' + } + } + }); + + const parsedSchema = commandOptionsSchema.safeParse({ + folderName: 'Contoso', + messageFilter: filterQuery, + sourceFoldersIds: 'foo' + }); + await assert.rejects(command.action(logger, { options: parsedSchema.data }), new CommandError('Id is malformed.')); + }); + + it('correctly handles error when invalid query is specified', async () => { + sinon.stub(request, 'post').rejects({ + error: { + error: { + code: "ErrorParsingFilterQuery-ParseUri", + message: "An unknown function with name 'contais' was found. This may also be a function import or a key lookup on a navigation property, which is not allowed." + } + } + }); + + const parsedSchema = commandOptionsSchema.safeParse({ + folderName: 'Contoso', + messageFilter: "contais(subject, 'CLI for Microsoft 365')", + sourceFoldersIds: 'foo' + }); + await assert.rejects(command.action(logger, { options: parsedSchema.data }), new CommandError(`An unknown function with name 'contais' was found. This may also be a function import or a key lookup on a navigation property, which is not allowed.`)); + }); +}); \ No newline at end of file diff --git a/src/m365/outlook/commands/mail/mail-searchfolder-add.ts b/src/m365/outlook/commands/mail/mail-searchfolder-add.ts new file mode 100644 index 00000000000..5b527db84a5 --- /dev/null +++ b/src/m365/outlook/commands/mail/mail-searchfolder-add.ts @@ -0,0 +1,106 @@ +import { MailSearchFolder } from '@microsoft/microsoft-graph-types'; +import { Logger } from '../../../../cli/Logger.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import GraphCommand from '../../../base/GraphCommand.js'; +import commands from '../../commands.js'; +import { z } from 'zod'; +import { globalOptionsZod } from '../../../../Command.js'; +import { zod } from '../../../../utils/zod.js'; +import { validation } from '../../../../utils/validation.js'; +import { accessToken } from '../../../../utils/accessToken.js'; +import auth from '../../../../Auth.js'; + +const options = globalOptionsZod + .extend({ + userId: zod.alias('i', z.string() + .refine(userId => validation.isValidGuid(userId), userId => ({ + message: `'${userId}' is not a valid GUID.` + })).optional()), + userName: zod.alias('n', z.string() + .refine(userName => validation.isValidUserPrincipalName(userName), userName => ({ + message: `'${userName}' is not a valid UPN.` + })).optional()), + folderName: z.string(), + messageFilter: z.string(), + sourceFoldersIds: z.string().transform((value) => value.split(',')).pipe(z.string().array()), + includeNestedFolders: z.boolean().optional() + }) + .strict(); + +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class OutlookMailSearchFolderAddCommand extends GraphCommand { + public get name(): string { + return commands.MAIL_SEARCHFOLDER_ADD; + } + + public get description(): string { + return `Creates a new mail search folder in the user's mailbox`; + } + + public get schema(): z.ZodTypeAny | undefined { + return options; + } + + public getRefinedSchema(schema: typeof options): z.ZodEffects | undefined { + return schema + .refine(options => !(options.userId && options.userName), { + message: 'Specify either userId or userName, but not both' + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + const isAppOnlyAccessToken = accessToken.isAppOnlyAccessToken(auth.connection.accessTokens[auth.defaultResource].accessToken); + + let requestUrl = `${this.resource}/v1.0/me/mailFolders/searchFolders/childFolders`; + + if (isAppOnlyAccessToken) { + if (!args.options.userId && !args.options.userName) { + throw 'When running with application permissions either userId or userName is required'; + } + + const userIdentifier = args.options.userId ?? args.options.userName; + + requestUrl = `${this.resource}/v1.0/users('${userIdentifier}')/mailFolders/searchFolders/childFolders`; + + if (args.options.verbose) { + await logger.logToStderr(`Creating a mail search folder in the mailbox of the user ${userIdentifier}...`); + } + } + else { + if (args.options.userId || args.options.userName) { + throw 'You can create mail search folder for other users only if CLI is authenticated in app-only mode'; + } + } + + const requestOptions: CliRequestOptions = { + url: requestUrl, + headers: { + accept: 'application/json;odata.metadata=none', + 'content-type': 'application/json' + }, + responseType: 'json', + data: { + '@odata.type': '#microsoft.graph.mailSearchFolder', + displayName: args.options.folderName, + includeNestedFolders: args.options.includeNestedFolders, + filterQuery: args.options.messageFilter, + sourceFolderIds: args.options.sourceFoldersIds + } + }; + + const result = await request.post(requestOptions); + await logger.log(result); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } +} + +export default new OutlookMailSearchFolderAddCommand(); \ No newline at end of file