@littlebearapps/outlook-assistant 3.12.1 → 3.14.0

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 (69) hide show
  1. package/.env.example +27 -3
  2. package/README.md +108 -33
  3. package/advanced/index.js +44 -174
  4. package/auth/auth-errors.js +23 -1
  5. package/auth/client-config.js +142 -0
  6. package/auth/index.js +4 -2
  7. package/auth/oauth-server.js +12 -2
  8. package/auth/token-manager.js +7 -3
  9. package/auth/token-storage.js +46 -33
  10. package/auth/tools.js +223 -93
  11. package/calendar/attendees.js +36 -0
  12. package/calendar/cancel.js +9 -25
  13. package/calendar/create.js +42 -48
  14. package/calendar/decline.js +10 -25
  15. package/calendar/delete.js +10 -25
  16. package/calendar/index.js +20 -37
  17. package/calendar/list.js +4 -16
  18. package/calendar/preview.js +335 -0
  19. package/calendar/update.js +42 -86
  20. package/categories/index.js +59 -264
  21. package/config.js +36 -2
  22. package/contacts/index.js +72 -128
  23. package/email/attachments.js +42 -124
  24. package/email/conversations.js +44 -78
  25. package/email/delta.js +10 -34
  26. package/email/draft.js +140 -96
  27. package/email/export.js +141 -110
  28. package/email/folder-utils.js +3 -2
  29. package/email/headers.js +11 -49
  30. package/email/index.js +85 -109
  31. package/email/list.js +4 -17
  32. package/email/mail-tips.js +86 -57
  33. package/email/mark-as-read.js +13 -49
  34. package/email/mime.js +14 -49
  35. package/email/read.js +16 -50
  36. package/email/search.js +46 -86
  37. package/email/send.js +82 -48
  38. package/folder/create.js +6 -25
  39. package/folder/delete.js +117 -38
  40. package/folder/index.js +17 -16
  41. package/folder/list.js +5 -17
  42. package/folder/move.js +13 -42
  43. package/folder/resolve.js +11 -6
  44. package/folder/stats.js +6 -20
  45. package/index.js +23 -45
  46. package/llms-install.md +31 -7
  47. package/llms.txt +19 -10
  48. package/outlook-auth-server.js +10 -3
  49. package/package.json +6 -2
  50. package/request-handler.js +217 -116
  51. package/rules/create.js +27 -70
  52. package/rules/index.js +30 -92
  53. package/rules/list.js +5 -17
  54. package/rules/rule-builder.js +57 -20
  55. package/rules/update.js +26 -60
  56. package/server.js +37 -0
  57. package/settings/index.js +142 -143
  58. package/tools.js +30 -0
  59. package/utils/field-presets.js +4 -2
  60. package/utils/graph-api.js +65 -22
  61. package/utils/logger.js +251 -0
  62. package/utils/mock-data.js +91 -2
  63. package/utils/read-only.js +59 -0
  64. package/utils/response-formatter.js +54 -15
  65. package/utils/risk-classes.js +324 -0
  66. package/utils/safe-write.js +372 -6
  67. package/utils/safety.js +109 -25
  68. package/utils/server-instructions.js +62 -0
  69. 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,55 @@ 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 one exists
71
+ // 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) {
81
+ const target = confineOutputTarget(args.savePath);
82
+ const resolved = target.path;
83
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
84
+ targetDir = resolved;
85
+ } else {
86
+ explicitFile = resolved;
87
+ requestedFile = target.requested;
88
+ explicitBase = target.base;
89
+ // Refuse early, before fetching; writeExplicitFile checks again.
90
+ if (!overwrite && pathEntryExists(explicitFile)) {
91
+ throw fileExistsError(requestedFile);
92
+ }
93
+ }
94
+ } else {
95
+ targetDir = confineOutputPath(os.tmpdir());
96
+ }
97
+ } catch (error) {
98
+ return outputPathError(error);
64
99
  }
65
100
 
66
101
  try {
@@ -77,14 +112,7 @@ async function handleExportEmail(args) {
77
112
  );
78
113
 
79
114
  if (!email) {
80
- return {
81
- content: [
82
- {
83
- type: 'text',
84
- text: `Email with ID ${emailId} not found.`,
85
- },
86
- ],
87
- };
115
+ return toolError(`Email with ID ${emailId} not found.`);
88
116
  }
89
117
 
90
118
  // Generate filename based on email metadata. The time matters: a
@@ -98,16 +126,6 @@ async function handleExportEmail(args) {
98
126
  // Paths claimed while writing this message (main file + attachments).
99
127
  const claimedPaths = new Set();
100
128
 
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
129
  // Export based on format
112
130
  let content;
113
131
  let attachmentsSaved = [];
@@ -130,34 +148,35 @@ async function handleExportEmail(args) {
130
148
  } else if (format === 'mbox' || format === 'html') {
131
149
  // F-26: clarify that mbox/html are conversation-only formats so
132
150
  // 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
- };
151
+ return toolError(
152
+ `Format '${format}' is only supported for target=conversation. For target=message use one of: ${Object.values(EXPORT_FORMATS).join(', ')}.`
153
+ );
141
154
  } 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
- };
155
+ return toolError(
156
+ `Unknown format: ${format}. Supported for target=message: ${Object.values(EXPORT_FORMATS).join(', ')}.`
157
+ );
150
158
  }
151
159
 
152
160
  // Save main file. Auto-create the directory so callers don't have to
153
- // pre-mkdir.
161
+ // pre-mkdir. An explicit file is created exclusively and replaces an
162
+ // existing file only with overwrite: true. A directory means we choose
163
+ // the name, so the write is exclusive (`wx`): it never clobbers an
164
+ // existing file and never follows a planted symlink.
154
165
  let finalPath;
166
+ let replaced = false;
155
167
  if (explicitFile) {
156
- finalPath = savePath;
157
- fs.mkdirSync(path.dirname(finalPath), { recursive: true });
158
- fs.writeFileSync(finalPath, content, 'utf8');
168
+ ({ path: finalPath, replaced } = writeExplicitFile(
169
+ explicitFile,
170
+ content,
171
+ {
172
+ overwrite,
173
+ encoding: 'utf8',
174
+ displayPath: requestedFile,
175
+ base: explicitBase,
176
+ }
177
+ ));
159
178
  } else {
160
- fs.mkdirSync(targetDir, { recursive: true });
179
+ ensureOutputDir(targetDir);
161
180
  finalPath = writeClaimedFile(
162
181
  targetDir,
163
182
  defaultBase,
@@ -184,6 +203,7 @@ async function handleExportEmail(args) {
184
203
  resultText += `| Property | Value |\n`;
185
204
  resultText += `|----------|-------|\n`;
186
205
  resultText += `| File | \`${finalPath}\` |\n`;
206
+ if (replaced) resultText += `| Replaced | yes (existing file) |\n`;
187
207
  resultText += `| Format | ${format.toUpperCase()} |\n`;
188
208
  resultText += `| Size | ${content.length.toLocaleString()} bytes |\n`;
189
209
  resultText += `| Subject | ${email.subject} |\n`;
@@ -206,6 +226,7 @@ async function handleExportEmail(args) {
206
226
  ],
207
227
  _meta: {
208
228
  filePath: finalPath,
229
+ replaced,
209
230
  format: format,
210
231
  sizeBytes: content.length,
211
232
  attachmentsSaved: attachmentsSaved.length,
@@ -213,28 +234,27 @@ async function handleExportEmail(args) {
213
234
  },
214
235
  };
215
236
  } catch (error) {
237
+ if (error instanceof OutputPathError) {
238
+ return outputPathError(error);
239
+ }
216
240
  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
- };
241
+ return authRequiredError();
225
242
  }
226
243
 
227
- return {
228
- content: [
229
- {
230
- type: 'text',
231
- text: `Export failed: ${error.message}`,
232
- },
233
- ],
234
- };
244
+ return toolError(`Export failed: ${error.message}`);
235
245
  }
236
246
  }
237
247
 
248
+ /**
249
+ * Tool error for a refused output path; rethrows anything else.
250
+ * @param {Error} error
251
+ * @returns {object} MCP error response
252
+ */
253
+ function outputPathError(error) {
254
+ if (!(error instanceof OutputPathError)) throw error;
255
+ return toolError(error.message, { nextStep: error.nextStep });
256
+ }
257
+
238
258
  /**
239
259
  * Batch export emails handler
240
260
  * @param {object} args - Tool arguments
@@ -255,28 +275,25 @@ async function handleBatchExportEmails(args) {
255
275
  searchQuery.subject = args.query;
256
276
  }
257
277
  const format = (args.format || EXPORT_FORMATS.MARKDOWN).toLowerCase();
258
- const outputDir = args.outputDir;
259
278
  const includeAttachments = args.includeAttachments === true; // Default false for batch
260
279
  // Scope the whole batch (search + per-message fetch + attachments) to a
261
280
  // shared/delegated mailbox when supplied.
262
281
  const mailbox = args.sharedMailbox || args.email || null;
263
282
  const prefix = buildMailboxPrefix(mailbox);
264
283
 
265
- if (!outputDir) {
266
- return {
267
- content: [
268
- {
269
- type: 'text',
270
- text: 'Output directory is required.',
271
- },
272
- ],
273
- };
284
+ if (!args.outputDir) {
285
+ return toolError('Output directory is required.');
274
286
  }
275
287
 
276
- // Ensure output directory exists
277
- if (!fs.existsSync(outputDir)) {
278
- fs.mkdirSync(outputDir, { recursive: true });
288
+ // Resolve and check the directory before creating it; write only to the
289
+ // resolved path.
290
+ let outputDir;
291
+ try {
292
+ outputDir = confineOutputPath(args.outputDir);
293
+ } catch (error) {
294
+ return outputPathError(error);
279
295
  }
296
+ ensureOutputDir(outputDir);
280
297
 
281
298
  try {
282
299
  const accessToken = await ensureAuthenticated();
@@ -290,24 +307,29 @@ async function handleBatchExportEmails(args) {
290
307
  mailbox
291
308
  );
292
309
  idsToExport = searchResults.map((e) => e.id);
310
+ // An empty match is a result, not an error.
311
+ if (idsToExport.length === 0) {
312
+ return {
313
+ content: [
314
+ {
315
+ type: 'text',
316
+ text: 'No emails matched the search query; nothing was exported.',
317
+ },
318
+ ],
319
+ };
320
+ }
293
321
  }
294
322
 
295
323
  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
- };
324
+ return toolError('No emails to export. Provide emailIds or searchQuery.');
304
325
  }
305
326
 
306
- // Limit batch size (per plan: max 100)
327
+ // Limit batch size (per plan: max 100), and say so in the result (#279)
307
328
  const maxBatch = 100;
329
+ const limitNote = batchLimitNote(idsToExport.length, emailIds, searchQuery);
308
330
  if (idsToExport.length > maxBatch) {
309
331
  idsToExport = idsToExport.slice(0, maxBatch);
310
- console.error(`Batch export limited to ${maxBatch} emails`);
332
+ log.debug(`Batch export limited to ${maxBatch} emails`);
311
333
  }
312
334
 
313
335
  // CSV batch export: aggregate all emails into a single CSV file
@@ -333,7 +355,7 @@ async function handleBatchExportEmails(args) {
333
355
 
334
356
  const csvContent = formatEmailsAsCSV(emails);
335
357
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
336
- fs.mkdirSync(outputDir, { recursive: true });
358
+ ensureOutputDir(outputDir);
337
359
  const csvPath = writeClaimedFile(
338
360
  outputDir,
339
361
  `batch_export_${timestamp}`,
@@ -353,6 +375,7 @@ async function handleBatchExportEmails(args) {
353
375
  resultText += `| Output File | \`${csvPath}\` |\n`;
354
376
  resultText += `| Format | CSV |\n`;
355
377
  resultText += `| Total Size | ${(totalBytes / 1024).toFixed(1)} KB |\n`;
378
+ resultText += limitNote;
356
379
 
357
380
  if (failed.length > 0) {
358
381
  resultText += `\n### Failed Exports\n\n`;
@@ -407,6 +430,7 @@ async function handleBatchExportEmails(args) {
407
430
  0
408
431
  );
409
432
  resultText += `| Total Size | ${(totalBytes / 1024).toFixed(1)} KB |\n`;
433
+ resultText += limitNote;
410
434
 
411
435
  // Requested id -> written path, so a caller can reconcile without
412
436
  // listing the directory. A batch that silently lost messages to
@@ -452,25 +476,32 @@ async function handleBatchExportEmails(args) {
452
476
  };
453
477
  } catch (error) {
454
478
  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
- };
479
+ return authRequiredError();
463
480
  }
464
481
 
465
- return {
466
- content: [
467
- {
468
- type: 'text',
469
- text: `Batch export failed: ${error.message}`,
470
- },
471
- ],
472
- };
482
+ return toolError(`Batch export failed: ${error.message}`);
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Says when a batch export was cut short, and how to get the rest (#279).
488
+ * target=messages exports at most 100 messages per call, and a search stops
489
+ * at searchQuery.maxResults (default 25, max 100).
490
+ * @param {number} found - IDs given or matched before the cap
491
+ * @param {string[]} emailIds - IDs the caller passed (empty for a search)
492
+ * @param {object} searchQuery - The search used when emailIds is empty
493
+ * @returns {string} - Markdown note, or '' when nothing was left out
494
+ */
495
+ function batchLimitNote(found, emailIds, searchQuery) {
496
+ if (emailIds.length > 0) {
497
+ if (found <= 100) return '';
498
+ 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
499
  }
500
+ const searchLimit = Math.min(searchQuery.maxResults || 25, 100);
501
+ if (found < searchLimit) return '';
502
+ const raise =
503
+ searchLimit < 100 ? 'raise `searchQuery.maxResults` (up to 100) or ' : '';
504
+ 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
505
  }
475
506
 
476
507
  /**
@@ -707,7 +738,7 @@ async function saveAttachments(
707
738
  }
708
739
  }
709
740
  } catch (error) {
710
- console.error(`Failed to save attachments: ${error.message}`);
741
+ log.debug(`Failed to save attachments: ${error.message}`);
711
742
  }
712
743
 
713
744
  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