@littlebearapps/outlook-assistant 3.13.0 → 3.14.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.
Files changed (67) hide show
  1. package/.env.example +30 -3
  2. package/README.md +67 -27
  3. package/advanced/index.js +44 -174
  4. package/auth/auth-errors.js +23 -1
  5. package/auth/oauth-server.js +7 -1
  6. package/auth/token-manager.js +7 -3
  7. package/auth/token-storage.js +28 -30
  8. package/auth/tools.js +61 -82
  9. package/calendar/attendees.js +36 -0
  10. package/calendar/cancel.js +9 -25
  11. package/calendar/create.js +42 -48
  12. package/calendar/decline.js +10 -25
  13. package/calendar/delete.js +10 -25
  14. package/calendar/index.js +20 -37
  15. package/calendar/list.js +4 -16
  16. package/calendar/preview.js +461 -0
  17. package/calendar/update.js +55 -83
  18. package/categories/index.js +68 -265
  19. package/config.js +29 -1
  20. package/contacts/index.js +72 -128
  21. package/email/attachments.js +43 -125
  22. package/email/conversations.js +44 -78
  23. package/email/delta.js +69 -46
  24. package/email/draft.js +170 -103
  25. package/email/export.js +145 -110
  26. package/email/folder-utils.js +3 -2
  27. package/email/headers.js +11 -49
  28. package/email/index.js +86 -110
  29. package/email/list.js +4 -17
  30. package/email/mail-tips.js +86 -57
  31. package/email/mark-as-read.js +13 -49
  32. package/email/mime.js +39 -51
  33. package/email/read.js +16 -50
  34. package/email/search.js +47 -87
  35. package/email/send.js +82 -48
  36. package/folder/create.js +6 -25
  37. package/folder/delete.js +117 -38
  38. package/folder/index.js +19 -17
  39. package/folder/list.js +5 -17
  40. package/folder/move.js +13 -42
  41. package/folder/resolve.js +11 -6
  42. package/folder/stats.js +18 -27
  43. package/index.js +39 -45
  44. package/llms-install.md +22 -4
  45. package/llms.txt +20 -11
  46. package/outlook-auth-server.js +10 -3
  47. package/package.json +4 -1
  48. package/request-handler.js +217 -116
  49. package/rules/create.js +28 -71
  50. package/rules/index.js +52 -93
  51. package/rules/list.js +7 -19
  52. package/rules/rule-builder.js +59 -22
  53. package/rules/update.js +27 -61
  54. package/server.js +41 -0
  55. package/settings/index.js +162 -145
  56. package/tools.js +30 -0
  57. package/utils/field-presets.js +4 -2
  58. package/utils/graph-api.js +65 -22
  59. package/utils/logger.js +251 -0
  60. package/utils/mock-data.js +91 -2
  61. package/utils/read-only.js +59 -0
  62. package/utils/response-formatter.js +54 -15
  63. package/utils/risk-classes.js +324 -0
  64. package/utils/safe-write.js +372 -6
  65. package/utils/safety.js +247 -42
  66. package/utils/server-instructions.js +73 -0
  67. package/utils/tool-error.js +33 -0
package/email/export.js CHANGED
@@ -20,7 +20,18 @@ const { resolveFolderPath } = require('./folder-utils');
20
20
  const { buildMailboxPrefix } = require('../utils/mailbox');
21
21
  const { quoteSearchPhrase } = require('../utils/odata-helpers');
22
22
  const { safeAttachmentFilename } = require('./attachments');
23
- const { writeClaimedFile } = require('../utils/safe-write');
23
+ const {
24
+ writeClaimedFile,
25
+ ensureOutputDir,
26
+ writeExplicitFile,
27
+ confineOutputPath,
28
+ confineOutputTarget,
29
+ fileExistsError,
30
+ pathEntryExists,
31
+ OutputPathError,
32
+ } = require('../utils/safe-write');
33
+ const { toolError, authRequiredError } = require('../utils/tool-error');
34
+ const { log } = require('../utils/logger');
24
35
 
25
36
  // Export format constants
26
37
  const EXPORT_FORMATS = {
@@ -36,31 +47,59 @@ const EXPORT_FORMATS = {
36
47
  * @param {object} args - Tool arguments
37
48
  * @param {string} args.id - Email ID (required)
38
49
  * @param {string} [args.format] - Export format (mime, eml, markdown, json)
39
- * @param {string} [args.savePath] - File path to save (optional)
50
+ * @param {string} [args.savePath] - File path or directory to save to (optional)
51
+ * @param {string} [args.outputDir] - Directory to save to (optional)
52
+ * @param {boolean} [args.overwrite] - Replace an existing savePath file (default: false)
40
53
  * @param {boolean} [args.includeAttachments] - Include attachments (default: true)
41
54
  * @returns {object} - MCP response with export status
42
55
  */
43
56
  async function handleExportEmail(args) {
44
57
  const emailId = args.id;
45
58
  const format = (args.format || EXPORT_FORMATS.MARKDOWN).toLowerCase();
46
- // F-27: accept `outputDir` (canonical) and `savePath` (legacy alias).
47
- // Previously single-message exports ignored outputDir entirely and
48
- // hardcoded os.tmpdir(), inconsistent with target=messages.
49
- const savePath = args.outputDir || args.savePath;
50
59
  const includeAttachments = args.includeAttachments !== false;
60
+ const overwrite = args.overwrite === true;
51
61
  // Message IDs are mailbox-scoped: route to /users/{mailbox} for a shared/
52
62
  // delegated mailbox, else /me.
53
63
  const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
54
64
 
55
65
  if (!emailId) {
56
- return {
57
- content: [
58
- {
59
- type: 'text',
60
- text: 'Email ID is required.',
61
- },
62
- ],
63
- };
66
+ return toolError('Email ID is required.');
67
+ }
68
+
69
+ // Where to write, checked before anything is fetched. F-27: `outputDir`
70
+ // is always a directory. `savePath` names a directory if it ends in a
71
+ // separator or one exists there, otherwise the file to write. With no path, the system temp
72
+ // directory is used. Writes go to the resolved path, never the raw one.
73
+ let explicitFile = null;
74
+ let requestedFile = null; // savePath as given, for messages
75
+ let explicitBase = null; // allowed directory it is in
76
+ let targetDir;
77
+ try {
78
+ if (args.outputDir) {
79
+ targetDir = confineOutputPath(args.outputDir);
80
+ } else if (args.savePath && /[\\/]$/.test(args.savePath)) {
81
+ // A trailing separator names a directory even when it doesn't exist
82
+ // yet; resolving the path drops it, so decide here (#301).
83
+ targetDir = confineOutputPath(args.savePath);
84
+ } else if (args.savePath) {
85
+ const target = confineOutputTarget(args.savePath);
86
+ const resolved = target.path;
87
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
88
+ targetDir = resolved;
89
+ } else {
90
+ explicitFile = resolved;
91
+ requestedFile = target.requested;
92
+ explicitBase = target.base;
93
+ // Refuse early, before fetching; writeExplicitFile checks again.
94
+ if (!overwrite && pathEntryExists(explicitFile)) {
95
+ throw fileExistsError(requestedFile);
96
+ }
97
+ }
98
+ } else {
99
+ targetDir = confineOutputPath(os.tmpdir());
100
+ }
101
+ } catch (error) {
102
+ return outputPathError(error);
64
103
  }
65
104
 
66
105
  try {
@@ -77,14 +116,7 @@ async function handleExportEmail(args) {
77
116
  );
78
117
 
79
118
  if (!email) {
80
- return {
81
- content: [
82
- {
83
- type: 'text',
84
- text: `Email with ID ${emailId} not found.`,
85
- },
86
- ],
87
- };
119
+ return toolError(`Email with ID ${emailId} not found.`);
88
120
  }
89
121
 
90
122
  // Generate filename based on email metadata. The time matters: a
@@ -98,16 +130,6 @@ async function handleExportEmail(args) {
98
130
  // Paths claimed while writing this message (main file + attachments).
99
131
  const claimedPaths = new Set();
100
132
 
101
- // Determine the save location. An explicit file path is the caller's to
102
- // control — honour it exactly, including overwriting, since that is what
103
- // an explicit path means. A directory (or the default temp dir) means we
104
- // choose the name, so the write is exclusive (`wx`): it never clobbers an
105
- // existing file and never follows a planted symlink.
106
- const explicitFile =
107
- savePath &&
108
- !(fs.existsSync(savePath) && fs.statSync(savePath).isDirectory());
109
- const targetDir = explicitFile ? null : savePath || os.tmpdir();
110
-
111
133
  // Export based on format
112
134
  let content;
113
135
  let attachmentsSaved = [];
@@ -130,34 +152,35 @@ async function handleExportEmail(args) {
130
152
  } else if (format === 'mbox' || format === 'html') {
131
153
  // F-26: clarify that mbox/html are conversation-only formats so
132
154
  // callers don't infer the format itself is unsupported.
133
- return {
134
- content: [
135
- {
136
- type: 'text',
137
- text: `Format '${format}' is only supported for target=conversation. For target=message use one of: ${Object.values(EXPORT_FORMATS).join(', ')}.`,
138
- },
139
- ],
140
- };
155
+ return toolError(
156
+ `Format '${format}' is only supported for target=conversation. For target=message use one of: ${Object.values(EXPORT_FORMATS).join(', ')}.`
157
+ );
141
158
  } else {
142
- return {
143
- content: [
144
- {
145
- type: 'text',
146
- text: `Unknown format: ${format}. Supported for target=message: ${Object.values(EXPORT_FORMATS).join(', ')}.`,
147
- },
148
- ],
149
- };
159
+ return toolError(
160
+ `Unknown format: ${format}. Supported for target=message: ${Object.values(EXPORT_FORMATS).join(', ')}.`
161
+ );
150
162
  }
151
163
 
152
164
  // Save main file. Auto-create the directory so callers don't have to
153
- // pre-mkdir.
165
+ // pre-mkdir. An explicit file is created exclusively and replaces an
166
+ // existing file only with overwrite: true. A directory means we choose
167
+ // the name, so the write is exclusive (`wx`): it never clobbers an
168
+ // existing file and never follows a planted symlink.
154
169
  let finalPath;
170
+ let replaced = false;
155
171
  if (explicitFile) {
156
- finalPath = savePath;
157
- fs.mkdirSync(path.dirname(finalPath), { recursive: true });
158
- fs.writeFileSync(finalPath, content, 'utf8');
172
+ ({ path: finalPath, replaced } = writeExplicitFile(
173
+ explicitFile,
174
+ content,
175
+ {
176
+ overwrite,
177
+ encoding: 'utf8',
178
+ displayPath: requestedFile,
179
+ base: explicitBase,
180
+ }
181
+ ));
159
182
  } else {
160
- fs.mkdirSync(targetDir, { recursive: true });
183
+ ensureOutputDir(targetDir);
161
184
  finalPath = writeClaimedFile(
162
185
  targetDir,
163
186
  defaultBase,
@@ -184,6 +207,7 @@ async function handleExportEmail(args) {
184
207
  resultText += `| Property | Value |\n`;
185
208
  resultText += `|----------|-------|\n`;
186
209
  resultText += `| File | \`${finalPath}\` |\n`;
210
+ if (replaced) resultText += `| Replaced | yes (existing file) |\n`;
187
211
  resultText += `| Format | ${format.toUpperCase()} |\n`;
188
212
  resultText += `| Size | ${content.length.toLocaleString()} bytes |\n`;
189
213
  resultText += `| Subject | ${email.subject} |\n`;
@@ -206,6 +230,7 @@ async function handleExportEmail(args) {
206
230
  ],
207
231
  _meta: {
208
232
  filePath: finalPath,
233
+ replaced,
209
234
  format: format,
210
235
  sizeBytes: content.length,
211
236
  attachmentsSaved: attachmentsSaved.length,
@@ -213,28 +238,27 @@ async function handleExportEmail(args) {
213
238
  },
214
239
  };
215
240
  } catch (error) {
241
+ if (error instanceof OutputPathError) {
242
+ return outputPathError(error);
243
+ }
216
244
  if (error.message === 'Authentication required') {
217
- return {
218
- content: [
219
- {
220
- type: 'text',
221
- text: "Authentication required. Please use the 'authenticate' tool first.",
222
- },
223
- ],
224
- };
245
+ return authRequiredError();
225
246
  }
226
247
 
227
- return {
228
- content: [
229
- {
230
- type: 'text',
231
- text: `Export failed: ${error.message}`,
232
- },
233
- ],
234
- };
248
+ return toolError(`Export failed: ${error.message}`);
235
249
  }
236
250
  }
237
251
 
252
+ /**
253
+ * Tool error for a refused output path; rethrows anything else.
254
+ * @param {Error} error
255
+ * @returns {object} MCP error response
256
+ */
257
+ function outputPathError(error) {
258
+ if (!(error instanceof OutputPathError)) throw error;
259
+ return toolError(error.message, { nextStep: error.nextStep });
260
+ }
261
+
238
262
  /**
239
263
  * Batch export emails handler
240
264
  * @param {object} args - Tool arguments
@@ -255,28 +279,25 @@ async function handleBatchExportEmails(args) {
255
279
  searchQuery.subject = args.query;
256
280
  }
257
281
  const format = (args.format || EXPORT_FORMATS.MARKDOWN).toLowerCase();
258
- const outputDir = args.outputDir;
259
282
  const includeAttachments = args.includeAttachments === true; // Default false for batch
260
283
  // Scope the whole batch (search + per-message fetch + attachments) to a
261
284
  // shared/delegated mailbox when supplied.
262
285
  const mailbox = args.sharedMailbox || args.email || null;
263
286
  const prefix = buildMailboxPrefix(mailbox);
264
287
 
265
- if (!outputDir) {
266
- return {
267
- content: [
268
- {
269
- type: 'text',
270
- text: 'Output directory is required.',
271
- },
272
- ],
273
- };
288
+ if (!args.outputDir) {
289
+ return toolError('Output directory is required.');
274
290
  }
275
291
 
276
- // Ensure output directory exists
277
- if (!fs.existsSync(outputDir)) {
278
- fs.mkdirSync(outputDir, { recursive: true });
292
+ // Resolve and check the directory before creating it; write only to the
293
+ // resolved path.
294
+ let outputDir;
295
+ try {
296
+ outputDir = confineOutputPath(args.outputDir);
297
+ } catch (error) {
298
+ return outputPathError(error);
279
299
  }
300
+ ensureOutputDir(outputDir);
280
301
 
281
302
  try {
282
303
  const accessToken = await ensureAuthenticated();
@@ -290,24 +311,29 @@ async function handleBatchExportEmails(args) {
290
311
  mailbox
291
312
  );
292
313
  idsToExport = searchResults.map((e) => e.id);
314
+ // An empty match is a result, not an error.
315
+ if (idsToExport.length === 0) {
316
+ return {
317
+ content: [
318
+ {
319
+ type: 'text',
320
+ text: 'No emails matched the search query; nothing was exported.',
321
+ },
322
+ ],
323
+ };
324
+ }
293
325
  }
294
326
 
295
327
  if (idsToExport.length === 0) {
296
- return {
297
- content: [
298
- {
299
- type: 'text',
300
- text: 'No emails to export. Provide emailIds or searchQuery.',
301
- },
302
- ],
303
- };
328
+ return toolError('No emails to export. Provide emailIds or searchQuery.');
304
329
  }
305
330
 
306
- // Limit batch size (per plan: max 100)
331
+ // Limit batch size (per plan: max 100), and say so in the result (#279)
307
332
  const maxBatch = 100;
333
+ const limitNote = batchLimitNote(idsToExport.length, emailIds, searchQuery);
308
334
  if (idsToExport.length > maxBatch) {
309
335
  idsToExport = idsToExport.slice(0, maxBatch);
310
- console.error(`Batch export limited to ${maxBatch} emails`);
336
+ log.debug(`Batch export limited to ${maxBatch} emails`);
311
337
  }
312
338
 
313
339
  // CSV batch export: aggregate all emails into a single CSV file
@@ -333,7 +359,7 @@ async function handleBatchExportEmails(args) {
333
359
 
334
360
  const csvContent = formatEmailsAsCSV(emails);
335
361
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
336
- fs.mkdirSync(outputDir, { recursive: true });
362
+ ensureOutputDir(outputDir);
337
363
  const csvPath = writeClaimedFile(
338
364
  outputDir,
339
365
  `batch_export_${timestamp}`,
@@ -353,6 +379,7 @@ async function handleBatchExportEmails(args) {
353
379
  resultText += `| Output File | \`${csvPath}\` |\n`;
354
380
  resultText += `| Format | CSV |\n`;
355
381
  resultText += `| Total Size | ${(totalBytes / 1024).toFixed(1)} KB |\n`;
382
+ resultText += limitNote;
356
383
 
357
384
  if (failed.length > 0) {
358
385
  resultText += `\n### Failed Exports\n\n`;
@@ -407,6 +434,7 @@ async function handleBatchExportEmails(args) {
407
434
  0
408
435
  );
409
436
  resultText += `| Total Size | ${(totalBytes / 1024).toFixed(1)} KB |\n`;
437
+ resultText += limitNote;
410
438
 
411
439
  // Requested id -> written path, so a caller can reconcile without
412
440
  // listing the directory. A batch that silently lost messages to
@@ -452,25 +480,32 @@ async function handleBatchExportEmails(args) {
452
480
  };
453
481
  } catch (error) {
454
482
  if (error.message === 'Authentication required') {
455
- return {
456
- content: [
457
- {
458
- type: 'text',
459
- text: "Authentication required. Please use the 'authenticate' tool first.",
460
- },
461
- ],
462
- };
483
+ return authRequiredError();
463
484
  }
464
485
 
465
- return {
466
- content: [
467
- {
468
- type: 'text',
469
- text: `Batch export failed: ${error.message}`,
470
- },
471
- ],
472
- };
486
+ return toolError(`Batch export failed: ${error.message}`);
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Says when a batch export was cut short, and how to get the rest (#279).
492
+ * target=messages exports at most 100 messages per call, and a search stops
493
+ * at searchQuery.maxResults (default 25, max 100).
494
+ * @param {number} found - IDs given or matched before the cap
495
+ * @param {string[]} emailIds - IDs the caller passed (empty for a search)
496
+ * @param {object} searchQuery - The search used when emailIds is empty
497
+ * @returns {string} - Markdown note, or '' when nothing was left out
498
+ */
499
+ function batchLimitNote(found, emailIds, searchQuery) {
500
+ if (emailIds.length > 0) {
501
+ if (found <= 100) return '';
502
+ return `\n> Exported the first 100 of ${found} requested messages (limit 100 per call). Export the remaining ${found - 100} IDs in another call.\n`;
473
503
  }
504
+ const searchLimit = Math.min(searchQuery.maxResults || 25, 100);
505
+ if (found < searchLimit) return '';
506
+ const raise =
507
+ searchLimit < 100 ? 'raise `searchQuery.maxResults` (up to 100) or ' : '';
508
+ return `\n> The search stopped at ${searchLimit} messages, the \`searchQuery.maxResults\` limit (default 25, max 100 per call), so more may match. To get the rest, ${raise}export in date ranges with \`searchQuery.receivedAfter\`/\`receivedBefore\`.\n`;
474
509
  }
475
510
 
476
511
  /**
@@ -707,7 +742,7 @@ async function saveAttachments(
707
742
  }
708
743
  }
709
744
  } catch (error) {
710
- console.error(`Failed to save attachments: ${error.message}`);
745
+ log.debug(`Failed to save attachments: ${error.message}`);
711
746
  }
712
747
 
713
748
  return saved;
@@ -3,6 +3,7 @@
3
3
  */
4
4
  const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
5
5
  const { buildMailboxPrefix } = require('../utils/mailbox');
6
+ const { log } = require('../utils/logger');
6
7
 
7
8
  /**
8
9
  * Cache of folder information to reduce API calls
@@ -74,7 +75,7 @@ async function resolveFolderPath(accessToken, folderName, mailbox = null) {
74
75
  // Check if it's a well-known folder (case-insensitive)
75
76
  const lowerFolderName = folderName.toLowerCase();
76
77
  if (WELL_KNOWN_FOLDERS[lowerFolderName]) {
77
- console.error(`Using well-known folder path for "${folderName}"`);
78
+ log.debug(`Using well-known folder path for "${folderName}"`);
78
79
  return scope(WELL_KNOWN_FOLDERS[lowerFolderName], prefix);
79
80
  }
80
81
 
@@ -93,7 +94,7 @@ async function resolveFolderPath(accessToken, folderName, mailbox = null) {
93
94
  mailbox,
94
95
  });
95
96
  const path = `${prefix}/mailFolders/${resolved.id}/messages`;
96
- console.error(`Resolved folder "${folderName}" to path: ${path}`);
97
+ log.debug(`Resolved folder "${folderName}" to path: ${path}`);
97
98
  return path;
98
99
  } catch (error) {
99
100
  // Surface not-found / ambiguity messages verbatim; wrap anything else.
package/email/headers.js CHANGED
@@ -7,6 +7,8 @@
7
7
  const { callGraphAPI } = require('../utils/graph-api');
8
8
  const { ensureAuthenticated } = require('../auth');
9
9
  const { buildMailboxPrefix } = require('../utils/mailbox');
10
+ const { toolError, authRequiredError } = require('../utils/tool-error');
11
+ const { log } = require('../utils/logger');
10
12
 
11
13
  /**
12
14
  * Important headers to highlight (in order of relevance)
@@ -164,14 +166,7 @@ async function handleGetEmailHeaders(args) {
164
166
  const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
165
167
 
166
168
  if (!emailId) {
167
- return {
168
- content: [
169
- {
170
- type: 'text',
171
- text: 'Email ID is required.',
172
- },
173
- ],
174
- };
169
+ return toolError('Email ID is required.');
175
170
  }
176
171
 
177
172
  try {
@@ -206,14 +201,7 @@ async function handleGetEmailHeaders(args) {
206
201
  );
207
202
 
208
203
  if (!email) {
209
- return {
210
- content: [
211
- {
212
- type: 'text',
213
- text: `Email with ID ${emailId} not found.`,
214
- },
215
- ],
216
- };
204
+ return toolError(`Email with ID ${emailId} not found.`);
217
205
  }
218
206
 
219
207
  const headers = email.internetMessageHeaders || [];
@@ -296,48 +284,22 @@ async function handleGetEmailHeaders(args) {
296
284
  },
297
285
  };
298
286
  } catch (error) {
299
- console.error(`Error getting email headers: ${error.message}`);
287
+ log.debug(`Error getting email headers: ${error.message}`);
300
288
 
301
289
  if (error.message.includes("doesn't belong to the targeted mailbox")) {
302
- return {
303
- content: [
304
- {
305
- type: 'text',
306
- text: `The email ID seems invalid or doesn't belong to your mailbox.`,
307
- },
308
- ],
309
- };
290
+ return toolError(
291
+ `The email ID seems invalid or doesn't belong to your mailbox.`
292
+ );
310
293
  }
311
294
 
312
- return {
313
- content: [
314
- {
315
- type: 'text',
316
- text: `Failed to get email headers: ${error.message}`,
317
- },
318
- ],
319
- };
295
+ return toolError(`Failed to get email headers: ${error.message}`);
320
296
  }
321
297
  } catch (error) {
322
298
  if (error.message === 'Authentication required') {
323
- return {
324
- content: [
325
- {
326
- type: 'text',
327
- text: "Authentication required. Please use the 'authenticate' tool first.",
328
- },
329
- ],
330
- };
299
+ return authRequiredError();
331
300
  }
332
301
 
333
- return {
334
- content: [
335
- {
336
- type: 'text',
337
- text: `Error accessing email: ${error.message}`,
338
- },
339
- ],
340
- };
302
+ return toolError(`Error accessing email: ${error.message}`);
341
303
  }
342
304
  }
343
305