Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New command: m365 outlook mail searchfolder add. Closes #6276 #6321

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const dictionary = [
'eventreceiver',
'external',
'externalize',
'folder',
'fun',
'group',
'groupify',
Expand Down Expand Up @@ -94,6 +95,7 @@ const dictionary = [
'role',
'room',
'schema',
'search',
'sensitivity',
'service',
'set',
Expand Down
128 changes: 128 additions & 0 deletions docs/docs/cmd/outlook/mail/mail-searchfolder-add.mdx
Original file line number Diff line number Diff line change
@@ -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 <folderName>`
: The name of the mail search folder.

`--sourceFolderIds <sourceFolderIds>`
: Comma-separated list of mail folders that should be searched.

`--includeNestedFolders`
: The nested mail folders will be searched if specified.

`--messageFilter <messageFilter>`
: The OData query to filter the messages.
```

<Global />

## 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

<Tabs>
<TabItem value="JSON">

```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')"
}
```

</TabItem>
<TabItem value="Text">

```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
```

</TabItem>
<TabItem value="CSV">

```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')"
```

</TabItem>
<TabItem value="Markdown">

```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')
```

</TabItem>
</Tabs>
5 changes: 5 additions & 0 deletions docs/src/config/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,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',
Expand Down
1 change: 1 addition & 0 deletions src/m365/outlook/commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const prefix: string = 'outlook';

export default {
MAIL_SEARCHFOLDER_ADD: `${prefix} mail searchfolder add`,
MAIL_SEND: `${prefix} mail send`,
MESSAGE_GET: `${prefix} message get`,
MESSAGE_LIST: `${prefix} message list`,
Expand Down
201 changes: 201 additions & 0 deletions src/m365/outlook/commands/mail/mail-searchfolder-add.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
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 { entraUser } from '../../../../utils/entraUser.js';
import { CommandError } from '../../../../Command.js';

describe(commands.MAIL_SEARCHFOLDER_ADD, () => {
const userId = 'ae0e8388-cd70-427f-9503-c57498ee3337';
const userName = 'john.doe@contoso.com';
const response = {
id: "AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQACAd7HWUedTo-i2ZIVhDiHAAoGOwIyAAA=",
displayName: "Contoso",
parentFolderId: "AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQA7DGC6yx9ARZqQFWs3P3q1AAAASBOLAAA=",
childFolderCount: 0,
unreadItemCount: 0,
totalItemCount: 5,
sizeInBytes: null,
isHidden: false,
isSupported: true,
includeNestedFolders: false,
sourceFolderIds: [
"AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQA7DGC6yx9ARZqQFWs3P3q1AAAASBOHAAA="
],
filterQuery: "subject eq 'Contoso'"
};
const responseWithNestedFolders = {
id: "AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQACAd7HWUedTo-i2ZIVhDiHAAoGOwIyAAA=",
displayName: "Contoso",
parentFolderId: "AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQA7DGC6yx9ARZqQFWs3P3q1AAAASBOLAAA=",
childFolderCount: 0,
unreadItemCount: 0,
totalItemCount: 5,
sizeInBytes: null,
isHidden: false,
isSupported: true,
includeNestedFolders: true,
sourceFolderIds: [
"AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQA7DGC6yx9ARZqQFWs3P3q1AAAASBOHAAA=",
"AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQA7DGC6yx9ARZqQFWs3P3q1AAAASBOHAAB="
],
filterQuery: "subject eq 'Contoso'"
};

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').returns();
sinon.stub(pid, 'getProcessName').returns('');
sinon.stub(session, 'getId').returns('');
auth.connection.active = true;
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');
});

afterEach(() => {
sinonUtil.restore([
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: `subject eq 'Contoso'`,
sourceFoldersIds: 'AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQDbzX51CNg_QrDdMlYeaLWqADbcwoABAAA='
});
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: `subject eq 'Contoso'`,
sourceFoldersIds: 'AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQDbzX51CNg_QrDdMlYeaLWqADbcwoABAAA='
});
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: `subject eq 'Contoso'`,
sourceFoldersIds: 'AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQDbzX51CNg_QrDdMlYeaLWqADbcwoABAAA='
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if folderName is not specified', () => {
const actual = commandOptionsSchema.safeParse({
userId: userId,
messageFilter: `subject eq 'Contoso'`,
sourceFoldersIds: 'AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQDbzX51CNg_QrDdMlYeaLWqADbcwoABAAA='
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if messageFilter is not specified', () => {
const actual = commandOptionsSchema.safeParse({
userId: userId,
folderName: 'Contoso',
sourceFoldersIds: 'AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQDbzX51CNg_QrDdMlYeaLWqADbcwoABAAA='
});
assert.notStrictEqual(actual.success, true);
});

it('fails validation if neither userId nor userName is specified', () => {
const actual = commandOptionsSchema.safeParse({
folderName: 'Contoso',
messageFilter: `subject eq 'Contoso'`,
sourceFoldersIds: 'AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQDbzX51CNg_QrDdMlYeaLWqADbcwoABAAA='
});
assert.notStrictEqual(actual.success, true);
});

it('correctly creates a mail search folder in the mailbox of a user specified by id', async () => {
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';
});
await command.action(logger, { options: { userId: userId, folderName: 'Contoso', messageFilter: `subject eq 'Contoso'`, sourceFoldersIds: 'AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQA7DGC6yx9ARZqQFWs3P3q1AAAASBOHAAA=' } });
assert(loggerLogSpy.calledOnceWithExactly(response));
});

it('correctly creates a mail search folder in the mailbox of a user specified by UPN', async () => {
sinon.stub(entraUser, 'getUserIdByUpn').resolves(userId);
sinon.stub(request, 'post').callsFake(async (opts) => {
if (opts.url === `https://graph.microsoft.com/v1.0/users/${userId}/mailFolders/searchFolders/childFolders`) {
return responseWithNestedFolders;
}

throw 'Invalid request';
});
await command.action(logger, { options: { userName: userName, folderName: 'Contoso', messageFilter: `subject eq 'Contoso'`, sourceFoldersIds: 'AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQA7DGC6yx9ARZqQFWs3P3q1AAAASBOHAAA=,AAMkAGRkZTFiMDQxLWYzNDgtNGQ3ZS05Y2U3LWU1NWJhMTM5YTgwMAAuAAAAAABxI4iNfZK7SYRiWw9sza20AQA7DGC6yx9ARZqQFWs3P3q1AAAASBOHAAB=', includeNestedFodlers: true, verbose: true } });
assert(loggerLogSpy.calledOnceWithExactly(responseWithNestedFolders));
});

it('correctly handles API OData error', async () => {
sinon.stub(request, 'post').rejects({
error: {
error: {
code: 'ErrorInvalidIdMalformed',
message: 'Id is malformed.'
}
}
});

await assert.rejects(command.action(logger, { options: { userId: userId, folderName: 'Contoso', messageFilter: `subject eq 'Contoso'`, sourceFoldersIds: 'foo' } } as any), new CommandError('Id is malformed.'));
});
});
Loading