@littlebearapps/outlook-assistant 3.8.2 → 3.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/folder/delete.js CHANGED
@@ -3,26 +3,21 @@
3
3
  */
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
- const { getFolderIdByName } = require('../email/folder-utils');
7
-
8
- /**
9
- * Protected folder names that cannot be deleted
10
- */
11
- const PROTECTED_FOLDERS = [
12
- 'inbox',
13
- 'drafts',
14
- 'sentitems',
15
- 'deleteditems',
16
- 'junkemail',
17
- 'archive',
18
- 'outbox',
19
- ];
6
+ const { resolveFolder, WELL_KNOWN } = require('./resolve');
20
7
 
21
8
  /**
22
9
  * Delete folder handler
10
+ *
11
+ * System folders are guarded by NAME/alias below — `WELL_KNOWN` covers the
12
+ * Graph names, display-name variants ("Sent Items", "Deleted Items") and short
13
+ * aliases ("sent"/"junk"/"spam"). A raw `folderId` that happens to point at a
14
+ * system folder is backstopped by Graph, which rejects deleting distinguished
15
+ * folders (mailFolder exposes no selectable `wellKnownName`, so we can't cheaply
16
+ * re-check protection on a resolved-by-ID folder).
17
+ *
23
18
  * @param {object} args - Tool arguments
24
19
  * @param {string} [args.folderId] - Folder ID to delete
25
- * @param {string} [args.folderName] - Folder name to delete (resolved to ID)
20
+ * @param {string} [args.folderName] - Folder name/path to delete (resolved to ID)
26
21
  * @returns {object} - MCP response
27
22
  */
28
23
  async function handleDeleteFolder(args) {
@@ -39,8 +34,8 @@ async function handleDeleteFolder(args) {
39
34
  };
40
35
  }
41
36
 
42
- // Guard against deleting protected folders
43
- if (folderName && PROTECTED_FOLDERS.includes(folderName.toLowerCase())) {
37
+ // Name/alias guard for the common accidental case.
38
+ if (folderName && WELL_KNOWN[folderName.toLowerCase().trim()]) {
44
39
  return {
45
40
  content: [
46
41
  {
@@ -54,32 +49,27 @@ async function handleDeleteFolder(args) {
54
49
  try {
55
50
  const accessToken = await ensureAuthenticated();
56
51
 
57
- let resolvedId = folderId;
58
-
59
- // Resolve folder name to ID if needed
60
- if (!resolvedId && folderName) {
61
- resolvedId = await getFolderIdByName(accessToken, folderName);
62
- if (!resolvedId) {
63
- return {
64
- content: [
65
- {
66
- type: 'text',
67
- text: `Folder "${folderName}" not found. Use folders (action=list) to see available folders.`,
68
- },
69
- ],
70
- };
71
- }
52
+ // Resolve (by name/path OR explicit ID) so nested folders are addressable
53
+ // and the confirmation can report the full path. (#216)
54
+ let resolved;
55
+ try {
56
+ resolved = await resolveFolder(accessToken, {
57
+ id: folderId,
58
+ name: folderName,
59
+ });
60
+ } catch (resolveError) {
61
+ return {
62
+ content: [{ type: 'text', text: resolveError.message }],
63
+ };
72
64
  }
73
65
 
74
66
  // Delete the folder
75
- await callGraphAPI(accessToken, 'DELETE', `me/mailFolders/${resolvedId}`);
76
-
77
- const displayName = folderName || resolvedId;
67
+ await callGraphAPI(accessToken, 'DELETE', `me/mailFolders/${resolved.id}`);
78
68
  return {
79
69
  content: [
80
70
  {
81
71
  type: 'text',
82
- text: `Folder "${displayName}" deleted successfully.`,
72
+ text: `Folder "${resolved.path}" deleted successfully.`,
83
73
  },
84
74
  ],
85
75
  };
package/folder/index.js CHANGED
@@ -12,7 +12,7 @@ const folderTools = [
12
12
  {
13
13
  name: 'folders',
14
14
  description:
15
- 'Manage mail folders (tool-level destructiveHint=true because `delete` permanently removes a folder; `list` and `stats` are read-only sub-actions despite the annotation). action=`list` (default) returns the folder tree with id/displayName/parentFolderId (toggle `includeItemCounts` for unread/total, `includeChildren` for hierarchy). action=`create` makes a new folder under the inbox (or under `folder`/`folderId`/`folderName`) and returns its id. action=`move` relocates emails (`emailIds` array) into `targetFolder`. action=`stats` returns counts (totalItemCount/unreadItemCount) suitable for pagination planning — pair with `outputVerbosity` to limit noise. action=`delete` permanently removes a folder and its contents — there is no recycle-bin recovery.',
15
+ "Manage mail folders (tool-level destructiveHint=true because `delete` permanently removes a folder; `list` and `stats` are read-only sub-actions despite the annotation). Folders can be addressed by name, by a slash-separated PATH for nested folders (e.g. `Triage/Delete`, `Inbox/Clients/Acme`, case-insensitive), or by explicit ID; `list` output includes each folder's full path and `[id: …]`. A bare name resolves a unique top-level folder first, then searches nested folders (ambiguous names return the candidates — disambiguate with a path or ID). action=`list` (default) returns the folder tree (toggle `includeItemCounts` for unread/total, `includeChildren` for hierarchy). action=`create` makes a new folder under the root, or under `parentFolder` (name/path) / `parentFolderId`, and returns its id. action=`move` relocates emails (`emailIds`) into `targetFolder` (name/path) or `targetFolderId`. action=`stats` returns counts (totalItemCount/unreadItemCount) for `folder` (name/path) or `folderId`, suitable for pagination planning. action=`delete` removes a folder (by `folderName`/path or `folderId`) and its contents — on Outlook.com the folder is moved to Deleted Items (recoverable until you empty it); M365/Exchange accounts may hard-delete per retention policy.",
16
16
  annotations: {
17
17
  title: 'Mail Folders',
18
18
  readOnlyHint: false,
@@ -43,7 +43,13 @@ const folderTools = [
43
43
  },
44
44
  parentFolder: {
45
45
  type: 'string',
46
- description: 'Parent folder name, default is root (action=create)',
46
+ description:
47
+ 'Parent folder name or path (e.g. "Clients/Acme"); default is root (action=create)',
48
+ },
49
+ parentFolderId: {
50
+ type: 'string',
51
+ description:
52
+ 'Parent folder ID — alternative to parentFolder for unambiguous targeting (action=create)',
47
53
  },
48
54
  // move params
49
55
  emailIds: {
@@ -53,7 +59,13 @@ const folderTools = [
53
59
  },
54
60
  targetFolder: {
55
61
  type: 'string',
56
- description: 'Folder name to move emails to (action=move, required)',
62
+ description:
63
+ 'Destination folder name or path, e.g. "Triage/Delete" (action=move; or use targetFolderId)',
64
+ },
65
+ targetFolderId: {
66
+ type: 'string',
67
+ description:
68
+ 'Destination folder ID — alternative to targetFolder for unambiguous/nested targeting (action=move)',
57
69
  },
58
70
  sourceFolder: {
59
71
  type: 'string',
@@ -63,22 +75,22 @@ const folderTools = [
63
75
  folder: {
64
76
  type: 'string',
65
77
  description:
66
- 'Folder name (inbox, sent, drafts, etc.). Default: inbox (action=stats)',
78
+ 'Folder name or path (inbox, sent, "Triage/Delete", etc.). Default: inbox (action=stats)',
67
79
  },
68
80
  outputVerbosity: {
69
81
  type: 'string',
70
82
  enum: ['minimal', 'standard', 'full'],
71
83
  description: 'Output detail level (action=stats, default: standard)',
72
84
  },
73
- // delete params
85
+ // delete/stats params
74
86
  folderId: {
75
87
  type: 'string',
76
- description: 'Folder ID to delete (action=delete)',
88
+ description: 'Folder ID (action=stats/delete)',
77
89
  },
78
90
  folderName: {
79
91
  type: 'string',
80
92
  description:
81
- 'Folder name to delete — resolved to ID (action=delete). Cannot delete protected folders (Inbox, Drafts, Sent, etc.)',
93
+ 'Folder name or path to delete — resolved to ID (action=delete). Cannot delete protected folders (Inbox, Drafts, Sent, etc.)',
82
94
  },
83
95
  },
84
96
  additionalProperties: false,
package/folder/list.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * List folders functionality
3
3
  */
4
- const { callGraphAPI } = require('../utils/graph-api');
5
4
  const { ensureAuthenticated } = require('../auth');
5
+ const { listChildFolders } = require('./resolve');
6
6
 
7
7
  /**
8
8
  * List folders handler
@@ -74,73 +74,54 @@ async function handleListFolders(args) {
74
74
  * @returns {Promise<Array>} - Array of folder objects with hierarchy
75
75
  */
76
76
  async function getAllFoldersHierarchy(accessToken, includeItemCounts) {
77
- try {
78
- // Determine select fields based on whether to include counts
79
- const selectFields = includeItemCounts
80
- ? 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount'
81
- : 'id,displayName,parentFolderId,childFolderCount';
82
-
83
- // Get all mail folders
84
- const response = await callGraphAPI(
85
- accessToken,
86
- 'GET',
87
- 'me/mailFolders',
88
- null,
89
- {
90
- $top: 100,
91
- $select: selectFields,
92
- }
93
- );
94
-
95
- if (!response.value) {
96
- return [];
77
+ // Determine select fields based on whether to include counts
78
+ const selectFields = includeItemCounts
79
+ ? 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount'
80
+ : 'id,displayName,parentFolderId,childFolderCount';
81
+
82
+ // Full recursive, paginated walk so nested folders at ANY depth appear with
83
+ // their complete path (not just one level). (#216 review)
84
+ const top = await listChildFolders(accessToken, null, selectFields);
85
+ const all = [];
86
+ const visited = new Set();
87
+ const queue = top.map((folder) => ({
88
+ folder,
89
+ path: folder.displayName,
90
+ parentPath: null,
91
+ depth: 1,
92
+ isTopLevel: true,
93
+ }));
94
+
95
+ for (let i = 0; i < queue.length; i++) {
96
+ const { folder, path, parentPath, depth, isTopLevel } = queue[i];
97
+ if (visited.has(folder.id)) {
98
+ continue;
97
99
  }
100
+ visited.add(folder.id);
101
+ all.push({ ...folder, path, parentFolder: parentPath, isTopLevel });
98
102
 
99
- // Get child folders for folders with children
100
- const foldersWithChildren = response.value.filter(
101
- (f) => f.childFolderCount > 0
102
- );
103
-
104
- const childFolderPromises = foldersWithChildren.map(async (folder) => {
103
+ if (folder.childFolderCount > 0 && depth < 20) {
104
+ let children;
105
105
  try {
106
- const childResponse = await callGraphAPI(
107
- accessToken,
108
- 'GET',
109
- `me/mailFolders/${folder.id}/childFolders`,
110
- null,
111
- { $select: selectFields }
112
- );
113
-
114
- // Add parent folder info to each child
115
- const childFolders = childResponse.value || [];
116
- childFolders.forEach((child) => {
117
- child.parentFolder = folder.displayName;
118
- });
119
-
120
- return childFolders;
106
+ children = await listChildFolders(accessToken, folder.id, selectFields);
121
107
  } catch (error) {
122
108
  console.error(
123
109
  `Error getting child folders for "${folder.displayName}": ${error.message}`
124
110
  );
125
- return [];
111
+ continue;
126
112
  }
127
- });
128
-
129
- const childFolders = await Promise.all(childFolderPromises);
130
- const allChildFolders = childFolders.flat();
131
-
132
- // Add top-level flag to parent folders
133
- const topLevelFolders = response.value.map((folder) => ({
134
- ...folder,
135
- isTopLevel: true,
136
- }));
137
-
138
- // Combine all folders
139
- return [...topLevelFolders, ...allChildFolders];
140
- } catch (error) {
141
- console.error(`Error getting all folders: ${error.message}`);
142
- throw error;
113
+ for (const child of children) {
114
+ queue.push({
115
+ folder: child,
116
+ path: `${path}/${child.displayName}`,
117
+ parentPath: path,
118
+ depth: depth + 1,
119
+ isTopLevel: false,
120
+ });
121
+ }
122
+ }
143
123
  }
124
+ return all;
144
125
  }
145
126
 
146
127
  /**
@@ -184,14 +165,13 @@ function formatFolderList(folders, includeItemCounts) {
184
165
  return a.displayName.localeCompare(b.displayName);
185
166
  });
186
167
 
187
- // Format each folder
168
+ // Format each folder. Emit the full path and folder ID so callers can
169
+ // address nested folders directly — `folders move targetFolder="Parent/Child"`
170
+ // or `targetFolderId=...`. (#216)
188
171
  const folderLines = sortedFolders.map((folder) => {
189
- let folderInfo = folder.displayName;
190
-
191
- // Add parent folder info if available
192
- if (folder.parentFolder) {
193
- folderInfo += ` (in ${folder.parentFolder})`;
194
- }
172
+ // Full path (computed during the recursive walk) so nested folders are
173
+ // addressable directly. (#216)
174
+ let folderInfo = folder.path || folder.displayName;
195
175
 
196
176
  // Add item counts if requested
197
177
  if (includeItemCounts) {
@@ -204,6 +184,8 @@ function formatFolderList(folders, includeItemCounts) {
204
184
  }
205
185
  }
206
186
 
187
+ folderInfo += ` [id: ${folder.id}]`;
188
+
207
189
  return folderInfo;
208
190
  });
209
191
 
@@ -269,6 +251,9 @@ function formatFolderHierarchy(folders, includeItemCounts) {
269
251
  }
270
252
  }
271
253
 
254
+ // Surface the folder ID so callers can address it directly. (#216)
255
+ line += ` [id: ${folder.id}]`;
256
+
272
257
  // Add children
273
258
  const childLines = folder.children
274
259
  .map((childId) => formatSubtree(childId, level + 1))
package/folder/move.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
- const { getFolderIdByName } = require('../email/folder-utils');
6
+ const { resolveFolder } = require('./resolve');
7
7
 
8
8
  /**
9
9
  * Move emails handler
@@ -13,6 +13,7 @@ const { getFolderIdByName } = require('../email/folder-utils');
13
13
  async function handleMoveEmails(args) {
14
14
  const emailIds = args.emailIds || '';
15
15
  const targetFolder = args.targetFolder || '';
16
+ const targetFolderId = args.targetFolderId || '';
16
17
  const sourceFolder = args.sourceFolder || '';
17
18
 
18
19
  if (!emailIds) {
@@ -26,12 +27,12 @@ async function handleMoveEmails(args) {
26
27
  };
27
28
  }
28
29
 
29
- if (!targetFolder) {
30
+ if (!targetFolder && !targetFolderId) {
30
31
  return {
31
32
  content: [
32
33
  {
33
34
  type: 'text',
34
- text: 'Target folder name is required.',
35
+ text: 'Target folder is required — pass `targetFolder` (name or "Parent/Child" path) or `targetFolderId`.',
35
36
  },
36
37
  ],
37
38
  };
@@ -62,7 +63,7 @@ async function handleMoveEmails(args) {
62
63
  const result = await moveEmailsToFolder(
63
64
  accessToken,
64
65
  ids,
65
- targetFolder,
66
+ { name: targetFolder, id: targetFolderId },
66
67
  sourceFolder
67
68
  );
68
69
 
@@ -101,28 +102,27 @@ async function handleMoveEmails(args) {
101
102
  * Move emails to a folder
102
103
  * @param {string} accessToken - Access token
103
104
  * @param {Array<string>} emailIds - Array of email IDs to move
104
- * @param {string} targetFolderName - Name of the target folder
105
+ * @param {{name?: string, id?: string}} targetSpec - Target folder name/path or ID
105
106
  * @param {string} sourceFolderName - Name of the source folder (optional)
106
107
  * @returns {Promise<object>} - Result object with status and message
107
108
  */
108
109
  async function moveEmailsToFolder(
109
110
  accessToken,
110
111
  emailIds,
111
- targetFolderName,
112
+ targetSpec,
112
113
  _sourceFolderName
113
114
  ) {
114
115
  try {
115
- // Get the target folder ID
116
- const targetFolderId = await getFolderIdByName(
117
- accessToken,
118
- targetFolderName
119
- );
120
- if (!targetFolderId) {
121
- return {
122
- success: false,
123
- message: `Target folder "${targetFolderName}" not found. Please specify a valid folder name.`,
124
- };
116
+ // Resolve the target folder (supports "Parent/Child" paths, aliases, and
117
+ // explicit IDs — nested folders are now addressable). (#216)
118
+ let target;
119
+ try {
120
+ target = await resolveFolder(accessToken, targetSpec);
121
+ } catch (resolveError) {
122
+ return { success: false, message: resolveError.message };
125
123
  }
124
+ const targetFolderId = target.id;
125
+ const targetLabel = target.path;
126
126
 
127
127
  // Track successful and failed moves
128
128
  const results = {
@@ -152,7 +152,7 @@ async function moveEmailsToFolder(
152
152
  let message = '';
153
153
 
154
154
  if (results.successful.length > 0) {
155
- message += `Successfully moved ${results.successful.length} email(s) to "${targetFolderName}".`;
155
+ message += `Successfully moved ${results.successful.length} email(s) to "${targetLabel}".`;
156
156
  }
157
157
 
158
158
  if (results.failed.length > 0) {