@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
@@ -14,14 +14,22 @@ const { ensureAuthenticated } = require('../auth');
14
14
  const { getEmailFields } = require('../utils/field-presets');
15
15
  const { resolveFolderPath } = require('./folder-utils');
16
16
  const { buildMailboxPrefix } = require('../utils/mailbox');
17
- const { writeClaimedFile, makeClaimedDir } = require('../utils/safe-write');
17
+ const {
18
+ writeClaimedFile,
19
+ ensureOutputDir,
20
+ makeClaimedDir,
21
+ confineOutputPath,
22
+ OutputPathError,
23
+ } = require('../utils/safe-write');
18
24
  const { escapeODataString } = require('../utils/odata-helpers');
19
25
  const {
20
26
  formatEmailContent,
21
27
  formatEmailsAsCSV,
22
28
  stripHtml,
23
29
  VERBOSITY,
30
+ DEFAULT_LIMITS,
24
31
  } = require('../utils/response-formatter');
32
+ const { toolError, authRequiredError } = require('../utils/tool-error');
25
33
  // Note: buildFromFilter/buildToFilter from search.js use OData $filter which causes
26
34
  // InefficientFilter on personal accounts with $orderby. Client-side filtering used instead.
27
35
 
@@ -235,20 +243,9 @@ async function handleListConversations(args) {
235
243
  };
236
244
  } catch (error) {
237
245
  if (error.message === 'Authentication required') {
238
- return {
239
- content: [
240
- {
241
- type: 'text',
242
- text: "Authentication required. Please use the 'authenticate' tool first.",
243
- },
244
- ],
245
- };
246
+ return authRequiredError();
246
247
  }
247
- return {
248
- content: [
249
- { type: 'text', text: `Error listing conversations: ${error.message}` },
250
- ],
251
- };
248
+ return toolError(`Error listing conversations: ${error.message}`);
252
249
  }
253
250
  }
254
251
 
@@ -264,8 +261,8 @@ const EXPORT_CONVERSATION_MESSAGE_LIMIT = 1000;
264
261
  * personal Microsoft accounts (400 InefficientFilter), so the query carries no
265
262
  * `$orderby`: the pages are fetched and the messages are sorted here. Paging
266
263
  * stops at the caller's `limit` or if Graph repeats a nextLink; either
267
- * way the result is marked truncated. (Not callGraphAPIPaginated: it can't
268
- * report truncation or catch a repeated nextLink.)
264
+ * way the result is marked truncated. (callGraphAPIPaginated stops the same
265
+ * way and reports `hasMore` since #279; this loop predates that.)
269
266
  * @param {string} accessToken - Access token
270
267
  * @param {string} prefix - Mailbox prefix (`me` or `users/{mailbox}`)
271
268
  * @param {string} conversationId - Conversation ID
@@ -364,9 +361,7 @@ async function handleGetConversation(args) {
364
361
  const prefix = buildMailboxPrefix(sharedMailbox);
365
362
 
366
363
  if (!conversationId) {
367
- return {
368
- content: [{ type: 'text', text: 'Conversation ID is required.' }],
369
- };
364
+ return toolError('Conversation ID is required.');
370
365
  }
371
366
 
372
367
  try {
@@ -386,14 +381,9 @@ async function handleGetConversation(args) {
386
381
  );
387
382
 
388
383
  if (messages.length === 0) {
389
- return {
390
- content: [
391
- {
392
- type: 'text',
393
- text: `No messages found for conversation ID: ${conversationId}`,
394
- },
395
- ],
396
- };
384
+ return toolError(
385
+ `No messages found for conversation ID: ${conversationId}`
386
+ );
397
387
  }
398
388
 
399
389
  // Format output
@@ -407,7 +397,13 @@ async function handleGetConversation(args) {
407
397
 
408
398
  messages.forEach((msg, index) => {
409
399
  output.push(`## Message ${index + 1} of ${messages.length}`);
410
- output.push(formatEmailContent(msg, verbosity, { includeHeaders }));
400
+ output.push(
401
+ formatEmailContent(msg, verbosity, {
402
+ includeHeaders,
403
+ sharedMailbox,
404
+ maxFullBodyChars: DEFAULT_LIMITS.maxFullBodyChars,
405
+ })
406
+ );
411
407
  output.push('\n---\n');
412
408
  });
413
409
 
@@ -427,20 +423,9 @@ async function handleGetConversation(args) {
427
423
  };
428
424
  } catch (error) {
429
425
  if (error.message === 'Authentication required') {
430
- return {
431
- content: [
432
- {
433
- type: 'text',
434
- text: "Authentication required. Please use the 'authenticate' tool first.",
435
- },
436
- ],
437
- };
426
+ return authRequiredError();
438
427
  }
439
- return {
440
- content: [
441
- { type: 'text', text: `Error getting conversation: ${error.message}` },
442
- ],
443
- };
428
+ return toolError(`Error getting conversation: ${error.message}`);
444
429
  }
445
430
  }
446
431
 
@@ -467,21 +452,22 @@ async function handleExportConversation(args) {
467
452
  const prefix = buildMailboxPrefix(sharedMailbox);
468
453
 
469
454
  if (!conversationId) {
470
- return {
471
- content: [{ type: 'text', text: 'Conversation ID is required.' }],
472
- };
455
+ return toolError('Conversation ID is required.');
473
456
  }
474
457
 
475
458
  const validFormats = ['eml', 'mbox', 'markdown', 'json', 'html', 'csv'];
476
459
  if (!validFormats.includes(format)) {
477
- return {
478
- content: [
479
- {
480
- type: 'text',
481
- text: `Invalid format. Use: ${validFormats.join(', ')}`,
482
- },
483
- ],
484
- };
460
+ return toolError(`Invalid format. Use: ${validFormats.join(', ')}`);
461
+ }
462
+
463
+ // Resolve and check the directory before fetching anything; write only to
464
+ // the resolved path.
465
+ let resolvedDir;
466
+ try {
467
+ resolvedDir = confineOutputPath(outputDir);
468
+ } catch (error) {
469
+ if (!(error instanceof OutputPathError)) throw error;
470
+ return toolError(error.message, { nextStep: error.nextStep });
485
471
  }
486
472
 
487
473
  try {
@@ -500,20 +486,14 @@ async function handleExportConversation(args) {
500
486
  );
501
487
 
502
488
  if (messages.length === 0) {
503
- return {
504
- content: [
505
- {
506
- type: 'text',
507
- text: `No messages found for conversation ID: ${conversationId}`,
508
- },
509
- ],
510
- };
489
+ return toolError(
490
+ `No messages found for conversation ID: ${conversationId}`
491
+ );
511
492
  }
512
493
 
513
494
  // Create output directory
514
- const resolvedDir = path.resolve(outputDir);
515
495
  if (!fs.existsSync(resolvedDir)) {
516
- fs.mkdirSync(resolvedDir, { recursive: true });
496
+ ensureOutputDir(resolvedDir);
517
497
  }
518
498
 
519
499
  // Generate filename base
@@ -788,23 +768,9 @@ async function handleExportConversation(args) {
788
768
  };
789
769
  } catch (error) {
790
770
  if (error.message === 'Authentication required') {
791
- return {
792
- content: [
793
- {
794
- type: 'text',
795
- text: "Authentication required. Please use the 'authenticate' tool first.",
796
- },
797
- ],
798
- };
771
+ return authRequiredError();
799
772
  }
800
- return {
801
- content: [
802
- {
803
- type: 'text',
804
- text: `Error exporting conversation: ${error.message}`,
805
- },
806
- ],
807
- };
773
+ return toolError(`Error exporting conversation: ${error.message}`);
808
774
  }
809
775
  }
810
776
 
package/email/delta.js CHANGED
@@ -10,6 +10,7 @@ const { formatEmailList, VERBOSITY } = require('../utils/response-formatter');
10
10
  const { getEmailFields } = require('../utils/field-presets');
11
11
  const { buildMailboxPrefix } = require('../utils/mailbox');
12
12
  const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
13
+ const { toolError, authRequiredError } = require('../utils/tool-error');
13
14
 
14
15
  /**
15
16
  * Extract the mailbox segment (`me` or `users/{address}`) from a delta/
@@ -82,6 +83,47 @@ function clampPageSize(value) {
82
83
  return Math.min(Math.max(Math.floor(n), 1), MAX_PAGE_SIZE);
83
84
  }
84
85
 
86
+ /**
87
+ * Sync phase of each continuation token this server issued (#262). A
88
+ * `$skiptoken` page belongs to whichever sync produced it, initial or
89
+ * incremental, and its URL doesn't say which, so remember it. Bounded; a
90
+ * continuation token not in here (e.g. after a restart) has phase 'unknown'.
91
+ */
92
+ const CONTINUATION_PHASES = new Map();
93
+ const MAX_TRACKED_CONTINUATIONS = 200;
94
+
95
+ function rememberPhase(token, phase) {
96
+ CONTINUATION_PHASES.delete(token);
97
+ CONTINUATION_PHASES.set(token, phase);
98
+ if (CONTINUATION_PHASES.size > MAX_TRACKED_CONTINUATIONS) {
99
+ CONTINUATION_PHASES.delete(CONTINUATION_PHASES.keys().next().value);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 'initial' (no token, or a continuation of an initial sync), 'incremental'
105
+ * (a delta token, or a continuation of one) or 'unknown' (a continuation
106
+ * token this server didn't issue).
107
+ * @param {string} [deltaToken]
108
+ * @returns {'initial'|'incremental'|'unknown'}
109
+ */
110
+ function syncPhase(deltaToken) {
111
+ if (!deltaToken) return 'initial';
112
+ if (CONTINUATION_PHASES.has(deltaToken)) {
113
+ return CONTINUATION_PHASES.get(deltaToken);
114
+ }
115
+ // A continuation token this server didn't issue (e.g. from before a
116
+ // restart) can't be placed; any other token is a delta token.
117
+ if (/[?&](\$|%24)skiptoken=/i.test(deltaToken)) return 'unknown';
118
+ return 'incremental';
119
+ }
120
+
121
+ const PHASE_LABEL = {
122
+ initial: 'Initial',
123
+ incremental: 'Incremental',
124
+ unknown: 'Continuation (initial or incremental unknown)',
125
+ };
126
+
85
127
  /**
86
128
  * List emails delta handler - incremental sync
87
129
  * @param {object} args - Tool arguments
@@ -116,16 +158,10 @@ async function handleListEmailsDelta(args) {
116
158
  // different mailbox rather than silently syncing the wrong one.
117
159
  const tokenMailbox = mailboxFromToken(deltaToken);
118
160
  if (tokenMailbox && mailboxesConflict(tokenMailbox, prefix)) {
119
- return {
120
- content: [
121
- {
122
- type: 'text',
123
- text:
124
- `Delta token mailbox mismatch: the token belongs to \`${tokenMailbox}\` but this call targets \`${prefix}\`.\n\n` +
125
- 'A delta token is bound to the mailbox and folder it was issued for. Use the token from that same mailbox/folder, or omit `deltaToken` to start a fresh initial sync here.',
126
- },
127
- ],
128
- };
161
+ return toolError(
162
+ `Delta token mailbox mismatch: the token belongs to \`${tokenMailbox}\` but this call targets \`${prefix}\`.\n\n` +
163
+ 'A delta token is bound to the mailbox and folder it was issued for. Use the token from that same mailbox/folder, or omit `deltaToken` to start a fresh initial sync here.'
164
+ );
129
165
  }
130
166
  endpoint = deltaToken;
131
167
  } else {
@@ -159,6 +195,7 @@ async function handleListEmailsDelta(args) {
159
195
  );
160
196
 
161
197
  // Process results
198
+ const phase = syncPhase(deltaToken);
162
199
  const emails = response.value || [];
163
200
  const nextLink = response['@odata.nextLink'];
164
201
  const deltaLink = response['@odata.deltaLink'];
@@ -180,20 +217,20 @@ async function handleListEmailsDelta(args) {
180
217
  removed: true,
181
218
  reason: email['@removed'].reason || 'deleted',
182
219
  });
183
- } else if (deltaToken) {
184
- // With deltaToken, all non-removed items are changes
185
- // We can't reliably distinguish created vs updated via delta
186
- changesSummary.updated++;
220
+ } else if (phase === 'initial') {
221
+ // Initial sync (any page) - all items are "created" for our purposes
222
+ changesSummary.created++;
187
223
  processedEmails.push(email);
188
224
  } else {
189
- // Initial sync - all items are "created" for our purposes
190
- changesSummary.created++;
225
+ // Incremental (or unknown) - every non-removed item is a change; Graph
226
+ // doesn't say whether it was created or updated
227
+ changesSummary.updated++;
191
228
  processedEmails.push(email);
192
229
  }
193
230
  }
194
231
 
195
232
  // Build response
196
- const isInitialSync = !deltaToken;
233
+ const isInitialSync = phase === 'initial';
197
234
  const hasMoreChanges = Boolean(nextLink);
198
235
  const newDeltaToken = deltaLink || nextLink;
199
236
  // F-15: nextLink is a continuation token (more pages of the same
@@ -201,6 +238,7 @@ async function handleListEmailsDelta(args) {
201
238
  // the initial sync finishes paging. Distinguish them in output so
202
239
  // callers know what they're storing.
203
240
  const tokenIsContinuation = !deltaLink && Boolean(nextLink);
241
+ if (tokenIsContinuation) rememberPhase(nextLink, phase);
204
242
 
205
243
  // Format output based on verbosity
206
244
  let resultText;
@@ -209,7 +247,7 @@ async function handleListEmailsDelta(args) {
209
247
  resultText += `| Metric | Value |\n`;
210
248
  resultText += `|--------|-------|\n`;
211
249
  resultText += `| Items | ${processedEmails.length} |\n`;
212
- resultText += `| Type | ${isInitialSync ? 'Initial' : 'Incremental'} |\n`;
250
+ resultText += `| Type | ${PHASE_LABEL[phase]} |\n`;
213
251
  resultText += `| More | ${hasMoreChanges ? 'Yes' : 'No'} |\n`;
214
252
  if (newDeltaToken) {
215
253
  const label = tokenIsContinuation
@@ -218,7 +256,7 @@ async function handleListEmailsDelta(args) {
218
256
  resultText += `\n**${label}**:\n\`\`\`\n${newDeltaToken}\n\`\`\`\n`;
219
257
  }
220
258
  } else {
221
- resultText = `## Delta Sync ${isInitialSync ? '(Initial)' : '(Incremental)'}\n\n`;
259
+ resultText = `## Delta Sync (${PHASE_LABEL[phase]})\n\n`;
222
260
 
223
261
  // Changes summary
224
262
  resultText += `### Changes Summary\n\n`;
@@ -236,8 +274,11 @@ async function handleListEmailsDelta(args) {
236
274
  const activeEmails = processedEmails.filter((e) => !e.removed);
237
275
  if (activeEmails.length > 0) {
238
276
  resultText += `\n### Emails\n\n`;
277
+ // formatEmailList takes (emails, folder, verbosity): the verbosity
278
+ // used to land in the folder slot ("Emails in standard", #306).
239
279
  resultText += formatEmailList(
240
280
  activeEmails,
281
+ deltaToken ? 'this sync page' : folder,
241
282
  verbosity === 'full' ? VERBOSITY.FULL : VERBOSITY.STANDARD
242
283
  );
243
284
  }
@@ -280,12 +321,12 @@ async function handleListEmailsDelta(args) {
280
321
  },
281
322
  ],
282
323
  _meta: {
283
- syncType: isInitialSync ? 'initial' : 'incremental',
324
+ syncType: phase,
284
325
  mailbox: sharedMailbox || 'me',
285
326
  // With a token the folder comes from the token, not the `folder` arg
286
327
  // (which is ignored) — don't echo a value we didn't use.
287
- folder: isInitialSync ? folder : null,
288
- folderSource: isInitialSync ? 'argument' : 'deltaToken',
328
+ folder: deltaToken ? null : folder,
329
+ folderSource: deltaToken ? 'deltaToken' : 'argument',
289
330
  itemCount: processedEmails.length,
290
331
  hasMoreChanges: hasMoreChanges,
291
332
  changesSummary: changesSummary,
@@ -295,14 +336,7 @@ async function handleListEmailsDelta(args) {
295
336
  };
296
337
  } catch (error) {
297
338
  if (error.message === 'Authentication required') {
298
- return {
299
- content: [
300
- {
301
- type: 'text',
302
- text: "Authentication required. Please use the 'authenticate' tool first.",
303
- },
304
- ],
305
- };
339
+ return authRequiredError();
306
340
  }
307
341
 
308
342
  // Handle expired delta token
@@ -310,25 +344,14 @@ async function handleListEmailsDelta(args) {
310
344
  error.message.includes('410') ||
311
345
  error.message.includes('resyncRequired')
312
346
  ) {
313
- return {
314
- content: [
315
- {
316
- type: 'text',
317
- text: `## Delta Token Expired\n\nThe provided delta token has expired. Please start a new initial sync by calling without a deltaToken.\n\n**Error:** ${error.message}`,
318
- },
319
- ],
320
- };
347
+ return toolError(
348
+ `## Delta Token Expired\n\nThe provided delta token has expired. Please start a new initial sync by calling without a deltaToken.\n\n**Error:** ${error.message}`
349
+ );
321
350
  }
322
351
 
323
- return {
324
- content: [
325
- {
326
- type: 'text',
327
- text: `Delta sync failed: ${error.message}`,
328
- },
329
- ],
330
- };
352
+ return toolError(`Delta sync failed: ${error.message}`);
331
353
  }
332
354
  }
333
355
 
334
356
  module.exports = handleListEmailsDelta;
357
+ module.exports.syncPhase = syncPhase;