@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
package/email/draft.js CHANGED
@@ -94,6 +94,51 @@ function formatDraftResponse(draft, actionLabel) {
94
94
  };
95
95
  }
96
96
 
97
+ /**
98
+ * Raised when update/send/delete is pointed at something that is not an
99
+ * unsent draft. Carries the user-facing message; handleError turns it into
100
+ * a tool error without the generic "Error ..." prefix.
101
+ */
102
+ class DraftGuardError extends Error {}
103
+
104
+ /**
105
+ * Refuse to mutate anything that is not an unsent draft. Graph's PATCH,
106
+ * DELETE and /send accept any message id, so without this check a received
107
+ * or sent message could be edited, deleted or re-sent (#246).
108
+ * @param {string} accessToken - Graph access token
109
+ * @param {string} id - Message id the caller passed as the draft id
110
+ * @param {string} action - The draft action being guarded (for the message)
111
+ * @throws {DraftGuardError} If the id is not a draft or does not exist
112
+ */
113
+ async function assertIsDraft(accessToken, id, action) {
114
+ let message;
115
+ try {
116
+ message = await callGraphAPI(
117
+ accessToken,
118
+ 'GET',
119
+ `me/messages/${id}`,
120
+ null,
121
+ {
122
+ $select: 'id,isDraft,subject',
123
+ }
124
+ );
125
+ } catch (error) {
126
+ if (/status 404\b|ErrorItemNotFound/.test(error.message)) {
127
+ throw new DraftGuardError(
128
+ `Draft not found: \`${id}\`. It may already have been sent or deleted, or the ID is wrong.`
129
+ );
130
+ }
131
+ throw error;
132
+ }
133
+
134
+ if (message?.isDraft !== true) {
135
+ const subject = message?.subject ? ` ("${message.subject}")` : '';
136
+ throw new DraftGuardError(
137
+ `Message \`${id}\`${subject} is not a draft, so draft action=${action} refused it and nothing was changed. update/send/delete only act on unsent drafts.`
138
+ );
139
+ }
140
+ }
141
+
97
142
  /**
98
143
  * Draft handler — routes to action-specific logic
99
144
  * @param {object} args - Tool arguments
@@ -242,12 +287,14 @@ async function handleUpdateDraft(args) {
242
287
  if (allowlistError) return allowlistError;
243
288
  }
244
289
 
245
- // Rate limit check
246
- const rateLimitError = checkRateLimit('draft');
247
- if (rateLimitError) return rateLimitError;
248
-
249
290
  try {
250
291
  const accessToken = await ensureAuthenticated();
292
+ // Refusals must not consume a rate-limit slot, so check before counting
293
+ await assertIsDraft(accessToken, id, 'update');
294
+
295
+ const rateLimitError = checkRateLimit('draft');
296
+ if (rateLimitError) return rateLimitError;
297
+
251
298
  const draft = await callGraphAPI(
252
299
  accessToken,
253
300
  'PATCH',
@@ -272,12 +319,14 @@ async function handleSendDraft(args) {
272
319
  };
273
320
  }
274
321
 
275
- // Rate limit via send-email counter (shares limit with direct sends)
276
- const rateLimitError = checkRateLimit('send-email');
277
- if (rateLimitError) return rateLimitError;
278
-
279
322
  try {
280
323
  const accessToken = await ensureAuthenticated();
324
+ await assertIsDraft(accessToken, id, 'send');
325
+
326
+ // Rate limit via send-email counter (shares limit with direct sends)
327
+ const rateLimitError = checkRateLimit('send-email');
328
+ if (rateLimitError) return rateLimitError;
329
+
281
330
  await callGraphAPI(accessToken, 'POST', `me/messages/${id}/send`);
282
331
  return {
283
332
  content: [
@@ -308,12 +357,13 @@ async function handleDeleteDraft(args) {
308
357
 
309
358
  try {
310
359
  const accessToken = await ensureAuthenticated();
360
+ await assertIsDraft(accessToken, id, 'delete');
311
361
  await callGraphAPI(accessToken, 'DELETE', `me/messages/${id}`);
312
362
  return {
313
363
  content: [
314
364
  {
315
365
  type: 'text',
316
- text: `Draft \`${id}\` deleted.`,
366
+ text: `Draft \`${id}\` deleted. It skips Deleted Items and goes to Recoverable Items, where Outlook's "Recover deleted items" can restore it for a limited time, depending on your account.`,
317
367
  },
318
368
  ],
319
369
  };
@@ -458,6 +508,13 @@ async function handleForwardDraft(args) {
458
508
  * Standard error handler
459
509
  */
460
510
  function handleError(actionLabel, error) {
511
+ if (error instanceof DraftGuardError) {
512
+ return {
513
+ content: [{ type: 'text', text: error.message }],
514
+ isError: true,
515
+ };
516
+ }
517
+
461
518
  if (error.message === 'Authentication required') {
462
519
  return {
463
520
  content: [
package/email/export.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Export emails to disk in MIME, Markdown, or JSON format.
5
5
  * Supports single and batch export with attachment handling.
6
6
  */
7
+ const crypto = require('crypto');
7
8
  const fs = require('fs');
8
9
  const os = require('os');
9
10
  const path = require('path');
@@ -15,6 +16,11 @@ const {
15
16
  VERBOSITY,
16
17
  } = require('../utils/response-formatter');
17
18
  const { getEmailFields } = require('../utils/field-presets');
19
+ const { resolveFolderPath } = require('./folder-utils');
20
+ const { buildMailboxPrefix } = require('../utils/mailbox');
21
+ const { quoteSearchPhrase } = require('../utils/odata-helpers');
22
+ const { safeAttachmentFilename } = require('./attachments');
23
+ const { writeClaimedFile } = require('../utils/safe-write');
18
24
 
19
25
  // Export format constants
20
26
  const EXPORT_FORMATS = {
@@ -42,6 +48,9 @@ async function handleExportEmail(args) {
42
48
  // hardcoded os.tmpdir(), inconsistent with target=messages.
43
49
  const savePath = args.outputDir || args.savePath;
44
50
  const includeAttachments = args.includeAttachments !== false;
51
+ // Message IDs are mailbox-scoped: route to /users/{mailbox} for a shared/
52
+ // delegated mailbox, else /me.
53
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
45
54
 
46
55
  if (!emailId) {
47
56
  return {
@@ -62,7 +71,7 @@ async function handleExportEmail(args) {
62
71
  const email = await callGraphAPI(
63
72
  accessToken,
64
73
  'GET',
65
- `me/messages/${emailId}`,
74
+ `${prefix}/messages/${emailId}`,
66
75
  null,
67
76
  { $select: selectFields }
68
77
  );
@@ -89,22 +98,15 @@ async function handleExportEmail(args) {
89
98
  // Paths claimed while writing this message (main file + attachments).
90
99
  const claimedPaths = new Set();
91
100
 
92
- // Determine final save path
93
- let finalPath;
94
- if (
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 =
95
107
  savePath &&
96
- !(fs.existsSync(savePath) && fs.statSync(savePath).isDirectory())
97
- ) {
98
- // An explicit file path is the caller's to control — honour it exactly,
99
- // including overwriting, since that is what an explicit path means.
100
- finalPath = savePath;
101
- } else {
102
- // A directory (or the default temp dir) means we choose the name, so
103
- // never clobber a file that is already there.
104
- const dir = savePath || os.tmpdir();
105
- fs.mkdirSync(dir, { recursive: true });
106
- finalPath = claimUniquePath(dir, defaultBase, extension, claimedPaths);
107
- }
108
+ !(fs.existsSync(savePath) && fs.statSync(savePath).isDirectory());
109
+ const targetDir = explicitFile ? null : savePath || os.tmpdir();
108
110
 
109
111
  // Export based on format
110
112
  let content;
@@ -112,7 +114,7 @@ async function handleExportEmail(args) {
112
114
 
113
115
  if (format === EXPORT_FORMATS.MIME || format === EXPORT_FORMATS.EML) {
114
116
  // MIME export - raw RFC822 format
115
- content = await callGraphAPIRaw(accessToken, emailId);
117
+ content = await callGraphAPIRaw(accessToken, emailId, prefix);
116
118
  } else if (format === EXPORT_FORMATS.MARKDOWN) {
117
119
  // Markdown export using existing formatter
118
120
  content = formatEmailContent(email, VERBOSITY.FULL, {
@@ -147,11 +149,24 @@ async function handleExportEmail(args) {
147
149
  };
148
150
  }
149
151
 
150
- // Auto-create the parent directory so callers don't have to pre-mkdir.
151
- fs.mkdirSync(path.dirname(finalPath), { recursive: true });
152
-
153
- // Save main file
154
- fs.writeFileSync(finalPath, content, 'utf8');
152
+ // Save main file. Auto-create the directory so callers don't have to
153
+ // pre-mkdir.
154
+ let finalPath;
155
+ if (explicitFile) {
156
+ finalPath = savePath;
157
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
158
+ fs.writeFileSync(finalPath, content, 'utf8');
159
+ } else {
160
+ fs.mkdirSync(targetDir, { recursive: true });
161
+ finalPath = writeClaimedFile(
162
+ targetDir,
163
+ defaultBase,
164
+ extension,
165
+ claimedPaths,
166
+ content,
167
+ 'utf8'
168
+ );
169
+ }
155
170
 
156
171
  // Handle attachments
157
172
  if (includeAttachments && email.hasAttachments) {
@@ -159,6 +174,7 @@ async function handleExportEmail(args) {
159
174
  accessToken,
160
175
  emailId,
161
176
  path.dirname(finalPath),
177
+ prefix,
162
178
  claimedPaths
163
179
  );
164
180
  }
@@ -241,6 +257,10 @@ async function handleBatchExportEmails(args) {
241
257
  const format = (args.format || EXPORT_FORMATS.MARKDOWN).toLowerCase();
242
258
  const outputDir = args.outputDir;
243
259
  const includeAttachments = args.includeAttachments === true; // Default false for batch
260
+ // Scope the whole batch (search + per-message fetch + attachments) to a
261
+ // shared/delegated mailbox when supplied.
262
+ const mailbox = args.sharedMailbox || args.email || null;
263
+ const prefix = buildMailboxPrefix(mailbox);
244
264
 
245
265
  if (!outputDir) {
246
266
  return {
@@ -266,7 +286,8 @@ async function handleBatchExportEmails(args) {
266
286
  if (Object.keys(searchQuery).length > 0 && emailIds.length === 0) {
267
287
  const searchResults = await searchEmailsForExport(
268
288
  accessToken,
269
- searchQuery
289
+ searchQuery,
290
+ mailbox
270
291
  );
271
292
  idsToExport = searchResults.map((e) => e.id);
272
293
  }
@@ -300,7 +321,7 @@ async function handleBatchExportEmails(args) {
300
321
  const email = await callGraphAPI(
301
322
  accessToken,
302
323
  'GET',
303
- `me/messages/${emailId}`,
324
+ `${prefix}/messages/${emailId}`,
304
325
  null,
305
326
  { $select: selectFields }
306
327
  );
@@ -312,8 +333,15 @@ async function handleBatchExportEmails(args) {
312
333
 
313
334
  const csvContent = formatEmailsAsCSV(emails);
314
335
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
315
- const csvPath = path.join(outputDir, `batch_export_${timestamp}.csv`);
316
- fs.writeFileSync(csvPath, csvContent, 'utf8');
336
+ fs.mkdirSync(outputDir, { recursive: true });
337
+ const csvPath = writeClaimedFile(
338
+ outputDir,
339
+ `batch_export_${timestamp}`,
340
+ 'csv',
341
+ null,
342
+ csvContent,
343
+ 'utf8'
344
+ );
317
345
  const totalBytes = Buffer.byteLength(csvContent, 'utf8');
318
346
 
319
347
  let resultText = `## Batch Export Complete\n\n`;
@@ -356,7 +384,8 @@ async function handleBatchExportEmails(args) {
356
384
  format,
357
385
  outputDir,
358
386
  includeAttachments,
359
- 4 // Max concurrent
387
+ 4, // Max concurrent
388
+ prefix
360
389
  );
361
390
 
362
391
  // Build response
@@ -446,8 +475,11 @@ async function handleBatchExportEmails(args) {
446
475
 
447
476
  /**
448
477
  * Search emails for batch export
478
+ * @param {string} accessToken
479
+ * @param {object} query
480
+ * @param {string|null} [mailbox] - Shared mailbox email, or null for the signed-in user
449
481
  */
450
- async function searchEmailsForExport(accessToken, query) {
482
+ async function searchEmailsForExport(accessToken, query, mailbox = null) {
451
483
  const folder = query.folder || 'inbox';
452
484
  const maxResults = Math.min(query.maxResults || 25, 100);
453
485
 
@@ -483,14 +515,17 @@ async function searchEmailsForExport(accessToken, query) {
483
515
  searchParts.push(`subject:${query.subject}`);
484
516
  }
485
517
  if (searchParts.length > 0) {
486
- params.$search = `"${searchParts.join(' ')}"`;
518
+ params.$search = quoteSearchPhrase(searchParts.join(' '));
487
519
  delete params.$orderby; // Can't combine $search with $orderby
488
520
  }
489
521
 
522
+ // resolveFolderPath handles well-known names, custom/localized names, nested
523
+ // paths, and raw IDs, scoped to the signed-in user or the shared mailbox.
524
+ const endpoint = await resolveFolderPath(accessToken, folder, mailbox);
490
525
  const response = await callGraphAPI(
491
526
  accessToken,
492
527
  'GET',
493
- `me/mailFolders/${folder}/messages`,
528
+ endpoint,
494
529
  null,
495
530
  params
496
531
  );
@@ -507,7 +542,8 @@ async function exportWithConcurrency(
507
542
  format,
508
543
  outputDir,
509
544
  includeAttachments,
510
- maxConcurrent
545
+ maxConcurrent,
546
+ prefix = 'me'
511
547
  ) {
512
548
  const results = [];
513
549
  const inProgress = new Set();
@@ -525,6 +561,7 @@ async function exportWithConcurrency(
525
561
  format,
526
562
  outputDir,
527
563
  includeAttachments,
564
+ prefix,
528
565
  claimedPaths
529
566
  ).then((result) => {
530
567
  inProgress.delete(promise);
@@ -553,6 +590,7 @@ async function exportSingleForBatch(
553
590
  format,
554
591
  outputDir,
555
592
  includeAttachments,
593
+ prefix = 'me',
556
594
  claimedPaths
557
595
  ) {
558
596
  try {
@@ -560,7 +598,7 @@ async function exportSingleForBatch(
560
598
  const email = await callGraphAPI(
561
599
  accessToken,
562
600
  'GET',
563
- `me/messages/${emailId}`,
601
+ `${prefix}/messages/${emailId}`,
564
602
  null,
565
603
  { $select: selectFields }
566
604
  );
@@ -568,16 +606,10 @@ async function exportSingleForBatch(
568
606
  const timestamp = filenameTimestamp(email.receivedDateTime);
569
607
  const safeSubject = sanitizeFilename(email.subject || 'no-subject');
570
608
  const extension = getExtension(format);
571
- const filePath = claimUniquePath(
572
- outputDir,
573
- `${timestamp}_${safeSubject}`,
574
- extension,
575
- claimedPaths
576
- );
577
609
 
578
610
  let content;
579
611
  if (format === EXPORT_FORMATS.MIME || format === EXPORT_FORMATS.EML) {
580
- content = await callGraphAPIRaw(accessToken, emailId);
612
+ content = await callGraphAPIRaw(accessToken, emailId, prefix);
581
613
  } else if (format === EXPORT_FORMATS.MARKDOWN) {
582
614
  content = formatEmailContent(email, VERBOSITY.FULL, {
583
615
  includeHeaders: true,
@@ -586,7 +618,14 @@ async function exportSingleForBatch(
586
618
  content = JSON.stringify(email, null, 2);
587
619
  }
588
620
 
589
- fs.writeFileSync(filePath, content, 'utf8');
621
+ const filePath = writeClaimedFile(
622
+ outputDir,
623
+ `${timestamp}_${safeSubject}`,
624
+ extension,
625
+ claimedPaths,
626
+ content,
627
+ 'utf8'
628
+ );
590
629
 
591
630
  // Handle attachments if requested
592
631
  let attachmentCount = 0;
@@ -595,6 +634,7 @@ async function exportSingleForBatch(
595
634
  accessToken,
596
635
  emailId,
597
636
  outputDir,
637
+ prefix,
598
638
  claimedPaths
599
639
  );
600
640
  attachmentCount = saved.length;
@@ -618,15 +658,25 @@ async function exportSingleForBatch(
618
658
 
619
659
  /**
620
660
  * Save email attachments to directory
661
+ * @param {string} accessToken
662
+ * @param {string} emailId
663
+ * @param {string} outputDir
664
+ * @param {string} [prefix] - Mailbox resource prefix (`me` or `users/{email}`)
621
665
  */
622
- async function saveAttachments(accessToken, emailId, outputDir, claimedPaths) {
666
+ async function saveAttachments(
667
+ accessToken,
668
+ emailId,
669
+ outputDir,
670
+ prefix = 'me',
671
+ claimedPaths
672
+ ) {
623
673
  const saved = [];
624
674
 
625
675
  try {
626
676
  const response = await callGraphAPI(
627
677
  accessToken,
628
678
  'GET',
629
- `me/messages/${emailId}/attachments`,
679
+ `${prefix}/messages/${emailId}/attachments`,
630
680
  null,
631
681
  { $select: 'id,name,contentBytes,size,contentType' }
632
682
  );
@@ -635,20 +685,20 @@ async function saveAttachments(accessToken, emailId, outputDir, claimedPaths) {
635
685
 
636
686
  for (const att of response.value) {
637
687
  if (att.contentBytes) {
638
- const safeFilename = sanitizeFilename(att.name || 'attachment');
639
- // `emailId.substring(0, 8)` was not a disambiguator: Graph message ids
640
- // within one mailbox share a long common prefix, so every message's
641
- // `invoice.pdf` resolved to the same path and all but the last were
642
- // overwritten. Claim a unique path instead.
688
+ // The attachment name is sender-controlled (GHSA-755c-c45g-69rv):
689
+ // reduce it to a safe basename. The per-message tag is derived from a
690
+ // hash of the id, never the raw caller-supplied id, so it can't carry
691
+ // path characters; uniqueness comes from the exclusive write.
692
+ const safeFilename = safeAttachmentFilename(att.name);
643
693
  const { base, extension } = splitExtension(safeFilename);
644
- const filePath = claimUniquePath(
694
+ const buffer = Buffer.from(att.contentBytes, 'base64');
695
+ const filePath = writeClaimedFile(
645
696
  outputDir,
646
- `${emailId.substring(0, 8)}_${base}`,
697
+ `${messageTag(emailId)}_${base}`,
647
698
  extension,
648
- claimedPaths || new Set()
699
+ claimedPaths,
700
+ buffer
649
701
  );
650
- const buffer = Buffer.from(att.contentBytes, 'base64');
651
- fs.writeFileSync(filePath, buffer);
652
702
  saved.push({
653
703
  filename: safeFilename,
654
704
  path: filePath,
@@ -681,32 +731,18 @@ function filenameTimestamp(isoDateTime) {
681
731
  }
682
732
 
683
733
  /**
684
- * Claim a not-yet-used path in `outputDir`, appending `_2`, `_3`, ... until the
685
- * name is free both on disk and among the paths already claimed in this batch.
686
- *
687
- * Silent overwrite is the dangerous part of the collision defect: the exporter
688
- * reported `Successful N / Failed 0` while messages vanished. Never overwrite —
689
- * disambiguate instead, and let the caller reconcile via the manifest.
690
- *
691
- * The claim is synchronous, so it is atomic with respect to the event loop and
692
- * safe under the batch exporter's 4-way concurrency even though the write
693
- * itself happens after an await.
694
- *
695
- * @param {string} outputDir - Target directory
696
- * @param {string} base - Filename without extension
697
- * @param {string} extension - Extension without a leading dot
698
- * @param {Set<string>} claimed - Paths already claimed by this batch
699
- * @returns {string} - An unused absolute path, now claimed
734
+ * Short, filesystem-safe tag for a message id. Graph ids in one mailbox share
735
+ * a long common prefix and are caller-supplied, so neither a raw prefix nor
736
+ * the id itself is usable in a filename.
737
+ * @param {string} emailId
738
+ * @returns {string} - 8 hex characters
700
739
  */
701
- function claimUniquePath(outputDir, base, extension, claimed) {
702
- let candidate = path.join(outputDir, `${base}.${extension}`);
703
- let suffix = 1;
704
- while (claimed.has(candidate) || fs.existsSync(candidate)) {
705
- suffix += 1;
706
- candidate = path.join(outputDir, `${base}_${suffix}.${extension}`);
707
- }
708
- claimed.add(candidate);
709
- return candidate;
740
+ function messageTag(emailId) {
741
+ return crypto
742
+ .createHash('sha256')
743
+ .update(String(emailId))
744
+ .digest('hex')
745
+ .slice(0, 8);
710
746
  }
711
747
 
712
748
  /**
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Email folder utilities
3
3
  */
4
- const { callGraphAPI } = require('../utils/graph-api');
5
- const { resolveFolder } = require('../folder/resolve');
4
+ const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
5
+ const { buildMailboxPrefix } = require('../utils/mailbox');
6
6
 
7
7
  /**
8
8
  * Cache of folder information to reduce API calls
@@ -46,31 +46,53 @@ const WELL_KNOWN_FOLDERS = {
46
46
  outbox: 'me/mailFolders/outbox/messages',
47
47
  };
48
48
 
49
+ /**
50
+ * Re-point a `me/...` well-known endpoint at a different mailbox.
51
+ * @param {string} endpoint - A WELL_KNOWN_FOLDERS value
52
+ * @param {string} prefix - `me` or `users/{email}`
53
+ * @returns {string}
54
+ */
55
+ function scope(endpoint, prefix) {
56
+ return prefix === 'me' ? endpoint : endpoint.replace(/^me\//, `${prefix}/`);
57
+ }
58
+
49
59
  /**
50
60
  * Resolve a folder name to its endpoint path
51
61
  * @param {string} accessToken - Access token
52
62
  * @param {string} folderName - Folder name to resolve
63
+ * @param {string|null} [mailbox] - Shared mailbox email, or null for the signed-in user
53
64
  * @returns {Promise<string>} - Resolved endpoint path
54
65
  */
55
- async function resolveFolderPath(accessToken, folderName) {
66
+ async function resolveFolderPath(accessToken, folderName, mailbox = null) {
67
+ const prefix = buildMailboxPrefix(mailbox);
68
+
56
69
  // Default to inbox if no folder specified
57
70
  if (!folderName) {
58
- return WELL_KNOWN_FOLDERS.inbox;
71
+ return scope(WELL_KNOWN_FOLDERS.inbox, prefix);
59
72
  }
60
73
 
61
74
  // Check if it's a well-known folder (case-insensitive)
62
75
  const lowerFolderName = folderName.toLowerCase();
63
76
  if (WELL_KNOWN_FOLDERS[lowerFolderName]) {
64
77
  console.error(`Using well-known folder path for "${folderName}"`);
65
- return WELL_KNOWN_FOLDERS[lowerFolderName];
78
+ return scope(WELL_KNOWN_FOLDERS[lowerFolderName], prefix);
79
+ }
80
+
81
+ // A raw folder ID is used as-is, as conversations/export did before name
82
+ // resolution was added (resolving it as a display name would fail).
83
+ if (looksLikeFolderId(folderName)) {
84
+ return `${prefix}/mailFolders/${folderName.trim()}/messages`;
66
85
  }
67
86
 
68
87
  try {
69
88
  // Path-aware resolution: supports nested folders ("Parent/Child"),
70
89
  // case-insensitive names, and reports ambiguity — the same shared resolver
71
90
  // the `folders` tool uses, so search can now scope to nested folders. (#216)
72
- const resolved = await resolveFolder(accessToken, { name: folderName });
73
- const path = `me/mailFolders/${resolved.id}/messages`;
91
+ const resolved = await resolveFolder(accessToken, {
92
+ name: folderName,
93
+ mailbox,
94
+ });
95
+ const path = `${prefix}/mailFolders/${resolved.id}/messages`;
74
96
  console.error(`Resolved folder "${folderName}" to path: ${path}`);
75
97
  return path;
76
98
  } catch (error) {
@@ -88,129 +110,7 @@ async function resolveFolderPath(accessToken, folderName) {
88
110
  }
89
111
  }
90
112
 
91
- /**
92
- * Get the ID of a mail folder by its name
93
- * @param {string} accessToken - Access token
94
- * @param {string} folderName - Name of the folder to find
95
- * @returns {Promise<string|null>} - Folder ID or null if not found
96
- */
97
- async function getFolderIdByName(accessToken, folderName) {
98
- try {
99
- // First try with exact match filter
100
- console.error(`Looking for folder with name "${folderName}"`);
101
- const response = await callGraphAPI(
102
- accessToken,
103
- 'GET',
104
- 'me/mailFolders',
105
- null,
106
- { $filter: `displayName eq '${folderName}'` }
107
- );
108
-
109
- if (response.value && response.value.length > 0) {
110
- console.error(
111
- `Found folder "${folderName}" with ID: ${response.value[0].id}`
112
- );
113
- return response.value[0].id;
114
- }
115
-
116
- // If exact match fails, try to get all folders and do a case-insensitive comparison
117
- console.error(
118
- `No exact match found for "${folderName}", trying case-insensitive search`
119
- );
120
- const allFoldersResponse = await callGraphAPI(
121
- accessToken,
122
- 'GET',
123
- 'me/mailFolders',
124
- null,
125
- { $top: 100 }
126
- );
127
-
128
- if (allFoldersResponse.value) {
129
- const lowerFolderName = folderName.toLowerCase();
130
- const matchingFolder = allFoldersResponse.value.find(
131
- (folder) => folder.displayName.toLowerCase() === lowerFolderName
132
- );
133
-
134
- if (matchingFolder) {
135
- console.error(
136
- `Found case-insensitive match for "${folderName}" with ID: ${matchingFolder.id}`
137
- );
138
- return matchingFolder.id;
139
- }
140
- }
141
-
142
- console.error(`No folder found matching "${folderName}"`);
143
- return null;
144
- } catch (error) {
145
- console.error(`Error finding folder "${folderName}": ${error.message}`);
146
- return null;
147
- }
148
- }
149
-
150
- /**
151
- * Get all mail folders
152
- * @param {string} accessToken - Access token
153
- * @returns {Promise<Array>} - Array of folder objects
154
- */
155
- async function getAllFolders(accessToken) {
156
- try {
157
- // Get top-level folders
158
- const response = await callGraphAPI(
159
- accessToken,
160
- 'GET',
161
- 'me/mailFolders',
162
- null,
163
- {
164
- $top: 100,
165
- $select:
166
- 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount',
167
- }
168
- );
169
-
170
- if (!response.value) {
171
- return [];
172
- }
173
-
174
- // Get child folders for folders with children
175
- const foldersWithChildren = response.value.filter(
176
- (f) => f.childFolderCount > 0
177
- );
178
-
179
- const childFolderPromises = foldersWithChildren.map(async (folder) => {
180
- try {
181
- const childResponse = await callGraphAPI(
182
- accessToken,
183
- 'GET',
184
- `me/mailFolders/${folder.id}/childFolders`,
185
- null,
186
- {
187
- $select:
188
- 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount',
189
- }
190
- );
191
-
192
- return childResponse.value || [];
193
- } catch (error) {
194
- console.error(
195
- `Error getting child folders for "${folder.displayName}": ${error.message}`
196
- );
197
- return [];
198
- }
199
- });
200
-
201
- const childFolders = await Promise.all(childFolderPromises);
202
-
203
- // Combine top-level folders and all child folders
204
- return [...response.value, ...childFolders.flat()];
205
- } catch (error) {
206
- console.error(`Error getting all folders: ${error.message}`);
207
- return [];
208
- }
209
- }
210
-
211
113
  module.exports = {
212
114
  WELL_KNOWN_FOLDERS,
213
115
  resolveFolderPath,
214
- getFolderIdByName,
215
- getAllFolders,
216
116
  };