@littlebearapps/outlook-assistant 3.11.2 → 3.12.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 (49) hide show
  1. package/.env.example +20 -0
  2. package/README.md +51 -28
  3. package/advanced/index.js +319 -46
  4. package/auth/device-code.js +100 -3
  5. package/auth/token-storage.js +44 -2
  6. package/auth/tools.js +196 -14
  7. package/calendar/attendees.js +101 -0
  8. package/calendar/cancel.js +5 -4
  9. package/calendar/create.js +15 -4
  10. package/calendar/decline.js +10 -5
  11. package/calendar/index.js +51 -10
  12. package/calendar/list.js +146 -3
  13. package/calendar/update.js +65 -33
  14. package/categories/index.js +17 -3
  15. package/config.js +103 -17
  16. package/contacts/index.js +2 -1
  17. package/email/attachments.js +19 -37
  18. package/email/conversations.js +180 -91
  19. package/email/delta.js +123 -13
  20. package/email/draft.js +66 -9
  21. package/email/export.js +113 -77
  22. package/email/folder-utils.js +29 -129
  23. package/email/headers.js +5 -1
  24. package/email/index.js +76 -19
  25. package/email/list.js +8 -1
  26. package/email/mark-as-read.js +3 -1
  27. package/email/mime.js +4 -1
  28. package/email/read.js +5 -1
  29. package/email/search.js +23 -9
  30. package/folder/create.js +11 -4
  31. package/folder/delete.js +9 -1
  32. package/folder/index.js +11 -1
  33. package/folder/list.js +61 -27
  34. package/folder/move.js +32 -7
  35. package/folder/resolve.js +65 -25
  36. package/folder/stats.js +11 -5
  37. package/index.js +9 -1
  38. package/llms-install.md +28 -9
  39. package/llms.txt +13 -9
  40. package/package.json +3 -3
  41. package/rules/index.js +3 -3
  42. package/rules/rule-builder.js +61 -16
  43. package/utils/datetime.js +170 -0
  44. package/utils/graph-api.js +390 -211
  45. package/utils/mailbox.js +77 -0
  46. package/utils/mock-data.js +3 -0
  47. package/utils/odata-helpers.js +24 -0
  48. package/utils/safe-write.js +151 -0
  49. package/calendar/accept.js +0 -72
@@ -12,6 +12,10 @@ const {
12
12
  } = require('../utils/graph-api');
13
13
  const { ensureAuthenticated } = require('../auth');
14
14
  const { getEmailFields } = require('../utils/field-presets');
15
+ const { resolveFolderPath } = require('./folder-utils');
16
+ const { buildMailboxPrefix } = require('../utils/mailbox');
17
+ const { writeClaimedFile, makeClaimedDir } = require('../utils/safe-write');
18
+ const { escapeODataString } = require('../utils/odata-helpers');
15
19
  const {
16
20
  formatEmailContent,
17
21
  formatEmailsAsCSV,
@@ -59,6 +63,8 @@ async function handleListConversations(args) {
59
63
  const folder = args.folder || 'inbox';
60
64
  const count = Math.min(args.count || 20, 50);
61
65
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
66
+ // Optional: scope to a shared/delegated mailbox instead of the signed-in user.
67
+ const sharedMailbox = args.sharedMailbox || args.email || null;
62
68
 
63
69
  try {
64
70
  const accessToken = await ensureAuthenticated();
@@ -77,7 +83,13 @@ async function handleListConversations(args) {
77
83
  'bodyPreview',
78
84
  ].join(',');
79
85
 
80
- const endpoint = `me/mailFolders/${folder}/messages`;
86
+ // resolveFolderPath handles well-known names, custom/localized names, nested
87
+ // paths, and raw IDs, scoped to the signed-in user or the shared mailbox.
88
+ const endpoint = await resolveFolderPath(
89
+ accessToken,
90
+ folder,
91
+ sharedMailbox
92
+ );
81
93
  const queryParams = {
82
94
  $select: selectFields,
83
95
  $orderby: 'receivedDateTime desc',
@@ -240,6 +252,102 @@ async function handleListConversations(args) {
240
252
  }
241
253
  }
242
254
 
255
+ // Upper bounds on the messages one conversation read/export will load. The
256
+ // inline read returns every message in the tool result, so it stops sooner.
257
+ const GET_CONVERSATION_MESSAGE_LIMIT = 100;
258
+ const EXPORT_CONVERSATION_MESSAGE_LIMIT = 1000;
259
+
260
+ /**
261
+ * Fetch the messages in a conversation, oldest first (or newest first).
262
+ *
263
+ * Graph rejects `$filter=conversationId eq '…'` combined with `$orderby` on
264
+ * personal Microsoft accounts (400 InefficientFilter), so the query carries no
265
+ * `$orderby`: the pages are fetched and the messages are sorted here. Paging
266
+ * 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.)
269
+ * @param {string} accessToken - Access token
270
+ * @param {string} prefix - Mailbox prefix (`me` or `users/{mailbox}`)
271
+ * @param {string} conversationId - Conversation ID
272
+ * @param {string} selectFields - `$select` fields
273
+ * @param {object} options
274
+ * @param {number} options.limit - Most messages to load
275
+ * @param {boolean} [options.newestFirst=false] - Sort newest first instead
276
+ * @returns {Promise<{messages: Array<object>, truncated: boolean}>}
277
+ */
278
+ async function fetchConversationMessages(
279
+ accessToken,
280
+ prefix,
281
+ conversationId,
282
+ selectFields,
283
+ { limit, newestFirst = false }
284
+ ) {
285
+ let messages = [];
286
+ let truncated = false;
287
+ const seenLinks = new Set();
288
+ let url = `${prefix}/messages`;
289
+ let queryParams = {
290
+ $select: selectFields,
291
+ $filter: `conversationId eq '${escapeODataString(String(conversationId))}'`,
292
+ $top: Math.min(100, limit),
293
+ };
294
+
295
+ while (url) {
296
+ const response = await callGraphAPI(
297
+ accessToken,
298
+ 'GET',
299
+ url,
300
+ null,
301
+ queryParams
302
+ );
303
+ messages.push(...(response.value || []));
304
+ const nextLink = response['@odata.nextLink'];
305
+ if (messages.length > limit) {
306
+ messages = messages.slice(0, limit);
307
+ truncated = true;
308
+ break;
309
+ }
310
+ if (nextLink && (messages.length >= limit || seenLinks.has(nextLink))) {
311
+ truncated = true;
312
+ break;
313
+ }
314
+ if (nextLink) seenLinks.add(nextLink);
315
+ url = nextLink;
316
+ queryParams = {}; // the nextLink already carries every parameter
317
+ }
318
+
319
+ return { messages: sortByReceivedDate(messages, newestFirst), truncated };
320
+ }
321
+
322
+ /**
323
+ * Note added to output when a conversation hit a fetch limit.
324
+ * @param {number} count - Messages loaded
325
+ * @returns {string}
326
+ */
327
+ function truncationNote(count) {
328
+ return `**Note**: Conversation truncated at ${count} messages.`;
329
+ }
330
+
331
+ /**
332
+ * Sort messages by receivedDateTime (stable; messages without a valid date
333
+ * go last in either direction).
334
+ * @param {Array<object>} messages - Messages to sort
335
+ * @param {boolean} newestFirst - Sort newest first instead of oldest first
336
+ * @returns {Array<object>} - New sorted array
337
+ */
338
+ function sortByReceivedDate(messages, newestFirst) {
339
+ const time = (msg) => {
340
+ const t = Date.parse(msg.receivedDateTime);
341
+ return Number.isNaN(t) ? null : t;
342
+ };
343
+ return [...messages].sort((a, b) => {
344
+ const ta = time(a);
345
+ const tb = time(b);
346
+ if (ta === null || tb === null) return (ta === null) - (tb === null);
347
+ return newestFirst ? tb - ta : ta - tb;
348
+ });
349
+ }
350
+
243
351
  /**
244
352
  * Get conversation handler - retrieves all messages in a thread
245
353
  * @param {object} args - Tool arguments
@@ -252,6 +360,8 @@ async function handleGetConversation(args) {
252
360
  const conversationId = args.conversationId;
253
361
  const includeHeaders = args.includeHeaders || false;
254
362
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
363
+ const sharedMailbox = args.sharedMailbox || args.email || null;
364
+ const prefix = buildMailboxPrefix(sharedMailbox);
255
365
 
256
366
  if (!conversationId) {
257
367
  return {
@@ -267,41 +377,13 @@ async function handleGetConversation(args) {
267
377
  const selectFields = getEmailFields(fieldPreset);
268
378
 
269
379
  // Search all folders for messages with this conversation ID
270
- const endpoint = 'me/messages';
271
- const queryParams = {
272
- $select: selectFields,
273
- $filter: `conversationId eq '${conversationId}'`,
274
- $orderby: 'receivedDateTime asc',
275
- $top: 100,
276
- };
277
-
278
- let response;
279
- try {
280
- response = await callGraphAPI(
281
- accessToken,
282
- 'GET',
283
- endpoint,
284
- null,
285
- queryParams
286
- );
287
- } catch (apiError) {
288
- if (
289
- apiError.message.includes('ErrorInvalidUrlQueryFilter') ||
290
- apiError.message.includes('InefficientFilter') ||
291
- apiError.message.includes('filter')
292
- ) {
293
- return {
294
- content: [
295
- {
296
- type: 'text',
297
- text: `Conversation retrieval by conversationId is not supported on personal Microsoft accounts. Use read-email with individual message IDs instead.`,
298
- },
299
- ],
300
- };
301
- }
302
- throw apiError;
303
- }
304
- const messages = response.value || [];
380
+ const { messages, truncated } = await fetchConversationMessages(
381
+ accessToken,
382
+ prefix,
383
+ conversationId,
384
+ selectFields,
385
+ { limit: GET_CONVERSATION_MESSAGE_LIMIT }
386
+ );
305
387
 
306
388
  if (messages.length === 0) {
307
389
  return {
@@ -320,6 +402,7 @@ async function handleGetConversation(args) {
320
402
  output.push(`**Subject**: ${messages[0].subject || '(no subject)'}`);
321
403
  output.push(`**Messages**: ${messages.length}`);
322
404
  output.push(`**Conversation ID**: \`${conversationId}\`\n`);
405
+ if (truncated) output.push(`${truncationNote(messages.length)}\n`);
323
406
  output.push('---\n');
324
407
 
325
408
  messages.forEach((msg, index) => {
@@ -339,6 +422,7 @@ async function handleGetConversation(args) {
339
422
  conversationId,
340
423
  messageCount: messages.length,
341
424
  subject: messages[0]?.subject,
425
+ truncated,
342
426
  },
343
427
  };
344
428
  } catch (error) {
@@ -379,6 +463,8 @@ async function handleExportConversation(args) {
379
463
  const outputDir = args.outputDir || require('os').tmpdir();
380
464
  const _includeAttachments = args.includeAttachments !== false;
381
465
  const order = args.order || 'chronological';
466
+ const sharedMailbox = args.sharedMailbox || args.email || null;
467
+ const prefix = buildMailboxPrefix(sharedMailbox);
382
468
 
383
469
  if (!conversationId) {
384
470
  return {
@@ -402,42 +488,16 @@ async function handleExportConversation(args) {
402
488
  const accessToken = await ensureAuthenticated();
403
489
 
404
490
  // Get all messages in conversation
405
- const selectFields = getEmailFields('export');
406
- const endpoint = 'me/messages';
407
- const queryParams = {
408
- $select: selectFields,
409
- $filter: `conversationId eq '${conversationId}'`,
410
- $orderby: `receivedDateTime ${order === 'reverse' ? 'desc' : 'asc'}`,
411
- $top: 100,
412
- };
413
-
414
- let response;
415
- try {
416
- response = await callGraphAPI(
417
- accessToken,
418
- 'GET',
419
- endpoint,
420
- null,
421
- queryParams
422
- );
423
- } catch (apiError) {
424
- if (
425
- apiError.message.includes('ErrorInvalidUrlQueryFilter') ||
426
- apiError.message.includes('InefficientFilter') ||
427
- apiError.message.includes('filter')
428
- ) {
429
- return {
430
- content: [
431
- {
432
- type: 'text',
433
- text: `Conversation export is not supported on personal Microsoft accounts. Use export with target=message and individual message IDs instead.`,
434
- },
435
- ],
436
- };
491
+ const { messages, truncated } = await fetchConversationMessages(
492
+ accessToken,
493
+ prefix,
494
+ conversationId,
495
+ getEmailFields('export'),
496
+ {
497
+ limit: EXPORT_CONVERSATION_MESSAGE_LIMIT,
498
+ newestFirst: order === 'reverse',
437
499
  }
438
- throw apiError;
439
- }
440
- const messages = response.value || [];
500
+ );
441
501
 
442
502
  if (messages.length === 0) {
443
503
  return {
@@ -461,26 +521,34 @@ async function handleExportConversation(args) {
461
521
  const date = formatDateForFilename(messages[0].receivedDateTime);
462
522
  const filenameBase = `${date}_${subject}_conversation`;
463
523
 
524
+ // Every file is written exclusively (never overwrites, never follows a
525
+ // symlink); a name already taken gets a -1, -2, … suffix.
526
+ const writeExport = (dir, base, extension, content) =>
527
+ writeClaimedFile(dir, base, extension, null, content, 'utf8');
528
+
464
529
  const exportedFiles = [];
465
530
  const exportStats = { messages: messages.length, attachments: 0, bytes: 0 };
466
531
 
467
532
  switch (format) {
468
533
  case 'eml': {
469
- // Export each message as individual .eml file
470
- const emlDir = path.join(resolvedDir, filenameBase);
471
- if (!fs.existsSync(emlDir)) {
472
- fs.mkdirSync(emlDir, { recursive: true });
473
- }
534
+ // Export each message as individual .eml file, into a directory this
535
+ // export creates (never an existing one, which could be a symlink).
536
+ const emlDir = makeClaimedDir(resolvedDir, filenameBase);
474
537
 
475
538
  for (let i = 0; i < messages.length; i++) {
476
539
  const msg = messages[i];
477
- const mimeContent = await callGraphAPIRaw(accessToken, msg.id);
540
+ const mimeContent = await callGraphAPIRaw(
541
+ accessToken,
542
+ msg.id,
543
+ prefix
544
+ );
478
545
  const msgDate = formatDateForFilename(msg.receivedDateTime);
479
- const emlPath = path.join(
546
+ const emlPath = writeExport(
480
547
  emlDir,
481
- `${i + 1}_${msgDate}_${sanitizeForFilename(msg.from?.emailAddress?.name || 'unknown', 20)}.eml`
548
+ `${i + 1}_${msgDate}_${sanitizeForFilename(msg.from?.emailAddress?.name || 'unknown', 20)}`,
549
+ 'eml',
550
+ mimeContent
482
551
  );
483
- fs.writeFileSync(emlPath, mimeContent, 'utf8');
484
552
  exportStats.bytes += Buffer.byteLength(mimeContent, 'utf8');
485
553
  exportedFiles.push(emlPath);
486
554
  }
@@ -489,11 +557,14 @@ async function handleExportConversation(args) {
489
557
 
490
558
  case 'mbox': {
491
559
  // Export all messages to single MBOX file
492
- const mboxPath = path.join(resolvedDir, `${filenameBase}.mbox`);
493
560
  let mboxContent = '';
494
561
 
495
562
  for (const msg of messages) {
496
- const mimeContent = await callGraphAPIRaw(accessToken, msg.id);
563
+ const mimeContent = await callGraphAPIRaw(
564
+ accessToken,
565
+ msg.id,
566
+ prefix
567
+ );
497
568
  const from = msg.from?.emailAddress?.address || 'unknown@unknown.com';
498
569
  const msgDate = new Date(msg.receivedDateTime);
499
570
  const mboxDate = msgDate.toUTCString().replace('GMT', '+0000');
@@ -504,7 +575,12 @@ async function handleExportConversation(args) {
504
575
  mboxContent += '\n\n';
505
576
  }
506
577
 
507
- fs.writeFileSync(mboxPath, mboxContent, 'utf8');
578
+ const mboxPath = writeExport(
579
+ resolvedDir,
580
+ filenameBase,
581
+ 'mbox',
582
+ mboxContent
583
+ );
508
584
  exportStats.bytes = Buffer.byteLength(mboxContent, 'utf8');
509
585
  exportedFiles.push(mboxPath);
510
586
  break;
@@ -512,7 +588,6 @@ async function handleExportConversation(args) {
512
588
 
513
589
  case 'markdown': {
514
590
  // Export as threaded Markdown document
515
- const mdPath = path.join(resolvedDir, `${filenameBase}.md`);
516
591
  const mdContent = [];
517
592
 
518
593
  mdContent.push(
@@ -559,7 +634,7 @@ async function handleExportConversation(args) {
559
634
  }
560
635
 
561
636
  const content = mdContent.join('\n');
562
- fs.writeFileSync(mdPath, content, 'utf8');
637
+ const mdPath = writeExport(resolvedDir, filenameBase, 'md', content);
563
638
  exportStats.bytes = Buffer.byteLength(content, 'utf8');
564
639
  exportedFiles.push(mdPath);
565
640
  break;
@@ -567,7 +642,6 @@ async function handleExportConversation(args) {
567
642
 
568
643
  case 'json': {
569
644
  // Export as JSON
570
- const jsonPath = path.join(resolvedDir, `${filenameBase}.json`);
571
645
  const jsonContent = JSON.stringify(
572
646
  {
573
647
  conversationId,
@@ -580,7 +654,12 @@ async function handleExportConversation(args) {
580
654
  2
581
655
  );
582
656
 
583
- fs.writeFileSync(jsonPath, jsonContent, 'utf8');
657
+ const jsonPath = writeExport(
658
+ resolvedDir,
659
+ filenameBase,
660
+ 'json',
661
+ jsonContent
662
+ );
584
663
  exportStats.bytes = Buffer.byteLength(jsonContent, 'utf8');
585
664
  exportedFiles.push(jsonPath);
586
665
  break;
@@ -588,7 +667,6 @@ async function handleExportConversation(args) {
588
667
 
589
668
  case 'html': {
590
669
  // Export as HTML document
591
- const htmlPath = path.join(resolvedDir, `${filenameBase}.html`);
592
670
  const htmlContent = [];
593
671
 
594
672
  htmlContent.push('<!DOCTYPE html>');
@@ -645,7 +723,12 @@ async function handleExportConversation(args) {
645
723
 
646
724
  htmlContent.push('</body></html>');
647
725
  const content = htmlContent.join('\n');
648
- fs.writeFileSync(htmlPath, content, 'utf8');
726
+ const htmlPath = writeExport(
727
+ resolvedDir,
728
+ filenameBase,
729
+ 'html',
730
+ content
731
+ );
649
732
  exportStats.bytes = Buffer.byteLength(content, 'utf8');
650
733
  exportedFiles.push(htmlPath);
651
734
  break;
@@ -653,9 +736,13 @@ async function handleExportConversation(args) {
653
736
 
654
737
  case 'csv': {
655
738
  // Export as CSV
656
- const csvPath = path.join(resolvedDir, `${filenameBase}.csv`);
657
739
  const csvContent = formatEmailsAsCSV(messages);
658
- fs.writeFileSync(csvPath, csvContent, 'utf8');
740
+ const csvPath = writeExport(
741
+ resolvedDir,
742
+ filenameBase,
743
+ 'csv',
744
+ csvContent
745
+ );
659
746
  exportStats.bytes = Buffer.byteLength(csvContent, 'utf8');
660
747
  exportedFiles.push(csvPath);
661
748
  break;
@@ -677,6 +764,7 @@ async function handleExportConversation(args) {
677
764
  output.push(`**Subject**: ${messages[0].subject || '(no subject)'}`);
678
765
  output.push(`**Format**: ${format.toUpperCase()}`);
679
766
  output.push(`**Messages**: ${exportStats.messages}`);
767
+ if (truncated) output.push(truncationNote(exportStats.messages));
680
768
  output.push(`**Total Size**: ${sizeFormatted}`);
681
769
  output.push(`**Output Directory**: ${resolvedDir}\n`);
682
770
  output.push('## Exported Files\n');
@@ -695,6 +783,7 @@ async function handleExportConversation(args) {
695
783
  messageCount: exportStats.messages,
696
784
  bytes: exportStats.bytes,
697
785
  files: exportedFiles,
786
+ truncated,
698
787
  },
699
788
  };
700
789
  } catch (error) {
package/email/delta.js CHANGED
@@ -8,21 +8,99 @@ const { callGraphAPI } = require('../utils/graph-api');
8
8
  const { ensureAuthenticated } = require('../auth');
9
9
  const { formatEmailList, VERBOSITY } = require('../utils/response-formatter');
10
10
  const { getEmailFields } = require('../utils/field-presets');
11
+ const { buildMailboxPrefix } = require('../utils/mailbox');
12
+ const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
13
+
14
+ /**
15
+ * Extract the mailbox segment (`me` or `users/{address}`) from a delta/
16
+ * continuation token URL. Returns null when the token carries no mailbox
17
+ * segment we can recognise (e.g. an opaque or relative value) — those are
18
+ * passed through untouched for backward compatibility.
19
+ * @param {string} token - Delta or continuation token (a full Graph URL)
20
+ * @returns {string|null} - Mailbox prefix found in the token path, or null
21
+ */
22
+ function mailboxFromToken(token) {
23
+ let pathname = token;
24
+ try {
25
+ pathname = new URL(token).pathname;
26
+ } catch {
27
+ // Not an absolute URL — match against the raw value.
28
+ }
29
+ const match = pathname.match(/(?:^|\/)(me|users\/[^/]+)(?:\/|$)/i);
30
+ if (!match) {
31
+ return null;
32
+ }
33
+ // Token URLs carry the percent-encoded form; local prefixes are raw.
34
+ // Decode so the two compare on equal footing.
35
+ try {
36
+ return decodeURIComponent(match[1]);
37
+ } catch {
38
+ return match[1];
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Decide whether a delta token's mailbox clearly differs from the target.
44
+ * Only identifiers of the same kind are compared: `me` against `me`, or an
45
+ * address against an address. Graph may hand back continuation links that
46
+ * name the mailbox by object ID (`users/<guid>`), which can't be matched to an
47
+ * address locally, so those are let through rather than wrongly rejected.
48
+ * @param {string} tokenMailbox - Mailbox segment from the token (`me` or `users/...`)
49
+ * @param {string} prefix - Mailbox prefix for this call (`me` or `users/...`)
50
+ * @returns {boolean} - True when the two identifiably name different mailboxes
51
+ */
52
+ function mailboxesConflict(tokenMailbox, prefix) {
53
+ const token = tokenMailbox.toLowerCase();
54
+ const target = prefix.toLowerCase();
55
+ if (token === target) return false;
56
+ const isAddress = (p) => p.startsWith('users/') && p.includes('@');
57
+ if (token === 'me' || target === 'me') {
58
+ // `me` versus a named mailbox is a mismatch, unless the named one is an
59
+ // opaque object ID that could be the signed-in user.
60
+ const other = token === 'me' ? target : token;
61
+ return isAddress(other);
62
+ }
63
+ return isAddress(token) && isAddress(target);
64
+ }
65
+
66
+ const DEFAULT_PAGE_SIZE = 100;
67
+ const MAX_PAGE_SIZE = 200;
68
+
69
+ /**
70
+ * Turn the caller's `maxResults` into a delta page size: an integer from 1 to
71
+ * 200. Missing or non-numeric values give the default (100); fractions are
72
+ * floored; zero and negatives become 1; anything over 200 becomes 200.
73
+ * @param {*} value - Raw `maxResults` argument
74
+ * @returns {number} - Page size to request
75
+ */
76
+ function clampPageSize(value) {
77
+ const n =
78
+ typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
79
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
80
+ return DEFAULT_PAGE_SIZE;
81
+ }
82
+ return Math.min(Math.max(Math.floor(n), 1), MAX_PAGE_SIZE);
83
+ }
11
84
 
12
85
  /**
13
86
  * List emails delta handler - incremental sync
14
87
  * @param {object} args - Tool arguments
15
88
  * @param {string} [args.folder] - Folder to sync (default: inbox)
16
89
  * @param {string} [args.deltaToken] - Token from previous delta call (omit for initial sync)
17
- * @param {number} [args.maxResults] - Max results per page (default: 100)
90
+ * @param {number} [args.maxResults] - Page size, 1-200 (default: 100). Sent as
91
+ * `Prefer: odata.maxpagesize` on every page; `$top` would cap the whole sync.
18
92
  * @param {string} [args.outputVerbosity] - Output detail level
19
93
  * @returns {object} - MCP response with emails, deltaToken, and change summary
20
94
  */
21
95
  async function handleListEmailsDelta(args) {
22
96
  const folder = args.folder || 'inbox';
23
97
  const deltaToken = args.deltaToken;
24
- const maxResults = Math.min(args.maxResults || 100, 200);
98
+ const maxResults = clampPageSize(args.maxResults);
25
99
  const verbosity = args.outputVerbosity || 'standard';
100
+ // Optional: scope the delta sync to a shared/delegated mailbox rather than
101
+ // the signed-in account. Accepts a custom/localized folder name or path.
102
+ const sharedMailbox = args.sharedMailbox || args.email || null;
103
+ const prefix = buildMailboxPrefix(sharedMailbox);
26
104
 
27
105
  try {
28
106
  const accessToken = await ensureAuthenticated();
@@ -32,24 +110,52 @@ async function handleListEmailsDelta(args) {
32
110
  let queryParams = {};
33
111
 
34
112
  if (deltaToken) {
35
- // Continue from previous sync - use deltaLink directly
113
+ // Continue from previous sync - use deltaLink directly. The token is
114
+ // authoritative: it already encodes the mailbox and folder, so the
115
+ // `folder`/`sharedMailbox` args are ignored. Reject a token from a
116
+ // different mailbox rather than silently syncing the wrong one.
117
+ const tokenMailbox = mailboxFromToken(deltaToken);
118
+ 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
+ };
129
+ }
36
130
  endpoint = deltaToken;
37
131
  } else {
38
- // Initial sync - start fresh
39
- endpoint = `me/mailFolders/${folder}/messages/delta`;
40
- queryParams = {
41
- $select: getEmailFields('delta'),
42
- $top: maxResults.toString(),
43
- };
132
+ // Initial sync - start fresh. Resolve the folder (well-known name,
133
+ // nested path, display name, or raw ID) within the target mailbox so
134
+ // custom subfolders work for shared mailboxes too.
135
+ // Resolution failures (not-found / ambiguous) carry their own actionable
136
+ // message; let them propagate to the handler's catch like any other error.
137
+ // A raw folder ID (accepted here before name resolution existed) is
138
+ // treated as an ID, not searched for as a display name.
139
+ const resolved = await resolveFolder(
140
+ accessToken,
141
+ looksLikeFolderId(folder)
142
+ ? { id: folder, mailbox: sharedMailbox }
143
+ : { name: folder, mailbox: sharedMailbox }
144
+ );
145
+ endpoint = `${prefix}/mailFolders/${resolved.id}/messages/delta`;
146
+ // No `$top`: on messages/delta it caps the whole sync, not the page.
147
+ queryParams = { $select: getEmailFields('delta') };
44
148
  }
45
149
 
46
- // Fetch delta results
150
+ // Fetch delta results. Graph honours the page size only on requests that
151
+ // carry the Prefer header, so continuation calls must send it too.
47
152
  const response = await callGraphAPI(
48
153
  accessToken,
49
154
  'GET',
50
155
  endpoint,
51
156
  null,
52
- deltaToken ? {} : queryParams
157
+ queryParams,
158
+ { Prefer: `odata.maxpagesize=${maxResults}` }
53
159
  );
54
160
 
55
161
  // Process results
@@ -151,7 +257,7 @@ async function handleListEmailsDelta(args) {
151
257
  // Pagination info
152
258
  if (hasMoreChanges) {
153
259
  resultText += `\n### More Pages Available\n`;
154
- resultText += `This page returned a continuation token. Call \`search-emails deltaMode=true deltaToken=<token>\` again to fetch the next page. The real delta token only emits once paging completes.\n`;
260
+ resultText += `This page returned a continuation token. Call \`search-emails deltaMode=true deltaToken=<token>\` again to fetch the next page, and pass the same \`maxResults\` on every page to keep the page size. The real delta token only emits once paging completes.\n`;
155
261
  }
156
262
 
157
263
  // Token (delta or continuation)
@@ -175,7 +281,11 @@ async function handleListEmailsDelta(args) {
175
281
  ],
176
282
  _meta: {
177
283
  syncType: isInitialSync ? 'initial' : 'incremental',
178
- folder: folder,
284
+ mailbox: sharedMailbox || 'me',
285
+ // With a token the folder comes from the token, not the `folder` arg
286
+ // (which is ignored) — don't echo a value we didn't use.
287
+ folder: isInitialSync ? folder : null,
288
+ folderSource: isInitialSync ? 'argument' : 'deltaToken',
179
289
  itemCount: processedEmails.length,
180
290
  hasMoreChanges: hasMoreChanges,
181
291
  changesSummary: changesSummary,