@littlebearapps/outlook-assistant 3.11.2 → 3.12.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.
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,9 @@ 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 { safeAttachmentFilename } = require('./attachments');
18
22
 
19
23
  // Export format constants
20
24
  const EXPORT_FORMATS = {
@@ -42,6 +46,9 @@ async function handleExportEmail(args) {
42
46
  // hardcoded os.tmpdir(), inconsistent with target=messages.
43
47
  const savePath = args.outputDir || args.savePath;
44
48
  const includeAttachments = args.includeAttachments !== false;
49
+ // Message IDs are mailbox-scoped: route to /users/{mailbox} for a shared/
50
+ // delegated mailbox, else /me.
51
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
45
52
 
46
53
  if (!emailId) {
47
54
  return {
@@ -62,7 +69,7 @@ async function handleExportEmail(args) {
62
69
  const email = await callGraphAPI(
63
70
  accessToken,
64
71
  'GET',
65
- `me/messages/${emailId}`,
72
+ `${prefix}/messages/${emailId}`,
66
73
  null,
67
74
  { $select: selectFields }
68
75
  );
@@ -89,22 +96,15 @@ async function handleExportEmail(args) {
89
96
  // Paths claimed while writing this message (main file + attachments).
90
97
  const claimedPaths = new Set();
91
98
 
92
- // Determine final save path
93
- let finalPath;
94
- if (
99
+ // Determine the save location. An explicit file path is the caller's to
100
+ // control — honour it exactly, including overwriting, since that is what
101
+ // an explicit path means. A directory (or the default temp dir) means we
102
+ // choose the name, so the write is exclusive (`wx`): it never clobbers an
103
+ // existing file and never follows a planted symlink.
104
+ const explicitFile =
95
105
  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
- }
106
+ !(fs.existsSync(savePath) && fs.statSync(savePath).isDirectory());
107
+ const targetDir = explicitFile ? null : savePath || os.tmpdir();
108
108
 
109
109
  // Export based on format
110
110
  let content;
@@ -112,7 +112,7 @@ async function handleExportEmail(args) {
112
112
 
113
113
  if (format === EXPORT_FORMATS.MIME || format === EXPORT_FORMATS.EML) {
114
114
  // MIME export - raw RFC822 format
115
- content = await callGraphAPIRaw(accessToken, emailId);
115
+ content = await callGraphAPIRaw(accessToken, emailId, prefix);
116
116
  } else if (format === EXPORT_FORMATS.MARKDOWN) {
117
117
  // Markdown export using existing formatter
118
118
  content = formatEmailContent(email, VERBOSITY.FULL, {
@@ -147,11 +147,24 @@ async function handleExportEmail(args) {
147
147
  };
148
148
  }
149
149
 
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');
150
+ // Save main file. Auto-create the directory so callers don't have to
151
+ // pre-mkdir.
152
+ let finalPath;
153
+ if (explicitFile) {
154
+ finalPath = savePath;
155
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
156
+ fs.writeFileSync(finalPath, content, 'utf8');
157
+ } else {
158
+ fs.mkdirSync(targetDir, { recursive: true });
159
+ finalPath = writeClaimedFile(
160
+ targetDir,
161
+ defaultBase,
162
+ extension,
163
+ claimedPaths,
164
+ content,
165
+ 'utf8'
166
+ );
167
+ }
155
168
 
156
169
  // Handle attachments
157
170
  if (includeAttachments && email.hasAttachments) {
@@ -159,6 +172,7 @@ async function handleExportEmail(args) {
159
172
  accessToken,
160
173
  emailId,
161
174
  path.dirname(finalPath),
175
+ prefix,
162
176
  claimedPaths
163
177
  );
164
178
  }
@@ -241,6 +255,10 @@ async function handleBatchExportEmails(args) {
241
255
  const format = (args.format || EXPORT_FORMATS.MARKDOWN).toLowerCase();
242
256
  const outputDir = args.outputDir;
243
257
  const includeAttachments = args.includeAttachments === true; // Default false for batch
258
+ // Scope the whole batch (search + per-message fetch + attachments) to a
259
+ // shared/delegated mailbox when supplied.
260
+ const mailbox = args.sharedMailbox || args.email || null;
261
+ const prefix = buildMailboxPrefix(mailbox);
244
262
 
245
263
  if (!outputDir) {
246
264
  return {
@@ -266,7 +284,8 @@ async function handleBatchExportEmails(args) {
266
284
  if (Object.keys(searchQuery).length > 0 && emailIds.length === 0) {
267
285
  const searchResults = await searchEmailsForExport(
268
286
  accessToken,
269
- searchQuery
287
+ searchQuery,
288
+ mailbox
270
289
  );
271
290
  idsToExport = searchResults.map((e) => e.id);
272
291
  }
@@ -300,7 +319,7 @@ async function handleBatchExportEmails(args) {
300
319
  const email = await callGraphAPI(
301
320
  accessToken,
302
321
  'GET',
303
- `me/messages/${emailId}`,
322
+ `${prefix}/messages/${emailId}`,
304
323
  null,
305
324
  { $select: selectFields }
306
325
  );
@@ -312,8 +331,15 @@ async function handleBatchExportEmails(args) {
312
331
 
313
332
  const csvContent = formatEmailsAsCSV(emails);
314
333
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
315
- const csvPath = path.join(outputDir, `batch_export_${timestamp}.csv`);
316
- fs.writeFileSync(csvPath, csvContent, 'utf8');
334
+ fs.mkdirSync(outputDir, { recursive: true });
335
+ const csvPath = writeClaimedFile(
336
+ outputDir,
337
+ `batch_export_${timestamp}`,
338
+ 'csv',
339
+ new Set(),
340
+ csvContent,
341
+ 'utf8'
342
+ );
317
343
  const totalBytes = Buffer.byteLength(csvContent, 'utf8');
318
344
 
319
345
  let resultText = `## Batch Export Complete\n\n`;
@@ -356,7 +382,8 @@ async function handleBatchExportEmails(args) {
356
382
  format,
357
383
  outputDir,
358
384
  includeAttachments,
359
- 4 // Max concurrent
385
+ 4, // Max concurrent
386
+ prefix
360
387
  );
361
388
 
362
389
  // Build response
@@ -446,8 +473,11 @@ async function handleBatchExportEmails(args) {
446
473
 
447
474
  /**
448
475
  * Search emails for batch export
476
+ * @param {string} accessToken
477
+ * @param {object} query
478
+ * @param {string|null} [mailbox] - Shared mailbox email, or null for the signed-in user
449
479
  */
450
- async function searchEmailsForExport(accessToken, query) {
480
+ async function searchEmailsForExport(accessToken, query, mailbox = null) {
451
481
  const folder = query.folder || 'inbox';
452
482
  const maxResults = Math.min(query.maxResults || 25, 100);
453
483
 
@@ -487,10 +517,13 @@ async function searchEmailsForExport(accessToken, query) {
487
517
  delete params.$orderby; // Can't combine $search with $orderby
488
518
  }
489
519
 
520
+ // resolveFolderPath handles well-known names, custom/localized names, nested
521
+ // paths, and raw IDs, scoped to the signed-in user or the shared mailbox.
522
+ const endpoint = await resolveFolderPath(accessToken, folder, mailbox);
490
523
  const response = await callGraphAPI(
491
524
  accessToken,
492
525
  'GET',
493
- `me/mailFolders/${folder}/messages`,
526
+ endpoint,
494
527
  null,
495
528
  params
496
529
  );
@@ -507,7 +540,8 @@ async function exportWithConcurrency(
507
540
  format,
508
541
  outputDir,
509
542
  includeAttachments,
510
- maxConcurrent
543
+ maxConcurrent,
544
+ prefix = 'me'
511
545
  ) {
512
546
  const results = [];
513
547
  const inProgress = new Set();
@@ -525,6 +559,7 @@ async function exportWithConcurrency(
525
559
  format,
526
560
  outputDir,
527
561
  includeAttachments,
562
+ prefix,
528
563
  claimedPaths
529
564
  ).then((result) => {
530
565
  inProgress.delete(promise);
@@ -553,6 +588,7 @@ async function exportSingleForBatch(
553
588
  format,
554
589
  outputDir,
555
590
  includeAttachments,
591
+ prefix = 'me',
556
592
  claimedPaths
557
593
  ) {
558
594
  try {
@@ -560,7 +596,7 @@ async function exportSingleForBatch(
560
596
  const email = await callGraphAPI(
561
597
  accessToken,
562
598
  'GET',
563
- `me/messages/${emailId}`,
599
+ `${prefix}/messages/${emailId}`,
564
600
  null,
565
601
  { $select: selectFields }
566
602
  );
@@ -568,16 +604,10 @@ async function exportSingleForBatch(
568
604
  const timestamp = filenameTimestamp(email.receivedDateTime);
569
605
  const safeSubject = sanitizeFilename(email.subject || 'no-subject');
570
606
  const extension = getExtension(format);
571
- const filePath = claimUniquePath(
572
- outputDir,
573
- `${timestamp}_${safeSubject}`,
574
- extension,
575
- claimedPaths
576
- );
577
607
 
578
608
  let content;
579
609
  if (format === EXPORT_FORMATS.MIME || format === EXPORT_FORMATS.EML) {
580
- content = await callGraphAPIRaw(accessToken, emailId);
610
+ content = await callGraphAPIRaw(accessToken, emailId, prefix);
581
611
  } else if (format === EXPORT_FORMATS.MARKDOWN) {
582
612
  content = formatEmailContent(email, VERBOSITY.FULL, {
583
613
  includeHeaders: true,
@@ -586,7 +616,14 @@ async function exportSingleForBatch(
586
616
  content = JSON.stringify(email, null, 2);
587
617
  }
588
618
 
589
- fs.writeFileSync(filePath, content, 'utf8');
619
+ const filePath = writeClaimedFile(
620
+ outputDir,
621
+ `${timestamp}_${safeSubject}`,
622
+ extension,
623
+ claimedPaths,
624
+ content,
625
+ 'utf8'
626
+ );
590
627
 
591
628
  // Handle attachments if requested
592
629
  let attachmentCount = 0;
@@ -595,6 +632,7 @@ async function exportSingleForBatch(
595
632
  accessToken,
596
633
  emailId,
597
634
  outputDir,
635
+ prefix,
598
636
  claimedPaths
599
637
  );
600
638
  attachmentCount = saved.length;
@@ -618,15 +656,25 @@ async function exportSingleForBatch(
618
656
 
619
657
  /**
620
658
  * Save email attachments to directory
659
+ * @param {string} accessToken
660
+ * @param {string} emailId
661
+ * @param {string} outputDir
662
+ * @param {string} [prefix] - Mailbox resource prefix (`me` or `users/{email}`)
621
663
  */
622
- async function saveAttachments(accessToken, emailId, outputDir, claimedPaths) {
664
+ async function saveAttachments(
665
+ accessToken,
666
+ emailId,
667
+ outputDir,
668
+ prefix = 'me',
669
+ claimedPaths
670
+ ) {
623
671
  const saved = [];
624
672
 
625
673
  try {
626
674
  const response = await callGraphAPI(
627
675
  accessToken,
628
676
  'GET',
629
- `me/messages/${emailId}/attachments`,
677
+ `${prefix}/messages/${emailId}/attachments`,
630
678
  null,
631
679
  { $select: 'id,name,contentBytes,size,contentType' }
632
680
  );
@@ -635,20 +683,20 @@ async function saveAttachments(accessToken, emailId, outputDir, claimedPaths) {
635
683
 
636
684
  for (const att of response.value) {
637
685
  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.
686
+ // The attachment name is sender-controlled (GHSA-755c-c45g-69rv):
687
+ // reduce it to a safe basename. The per-message tag is derived from a
688
+ // hash of the id, never the raw caller-supplied id, so it can't carry
689
+ // path characters; uniqueness comes from the exclusive write.
690
+ const safeFilename = safeAttachmentFilename(att.name);
643
691
  const { base, extension } = splitExtension(safeFilename);
644
- const filePath = claimUniquePath(
692
+ const buffer = Buffer.from(att.contentBytes, 'base64');
693
+ const filePath = writeClaimedFile(
645
694
  outputDir,
646
- `${emailId.substring(0, 8)}_${base}`,
695
+ `${messageTag(emailId)}_${base}`,
647
696
  extension,
648
- claimedPaths || new Set()
697
+ claimedPaths || new Set(),
698
+ buffer
649
699
  );
650
- const buffer = Buffer.from(att.contentBytes, 'base64');
651
- fs.writeFileSync(filePath, buffer);
652
700
  saved.push({
653
701
  filename: safeFilename,
654
702
  path: filePath,
@@ -699,16 +747,78 @@ function filenameTimestamp(isoDateTime) {
699
747
  * @returns {string} - An unused absolute path, now claimed
700
748
  */
701
749
  function claimUniquePath(outputDir, base, extension, claimed) {
702
- let candidate = path.join(outputDir, `${base}.${extension}`);
750
+ const root = path.resolve(outputDir);
751
+ const ext = extension ? `.${extension}` : '';
752
+ let candidate = path.join(root, `${base}${ext}`);
703
753
  let suffix = 1;
704
- while (claimed.has(candidate) || fs.existsSync(candidate)) {
754
+ while (claimed.has(candidate) || pathEntryExists(candidate)) {
705
755
  suffix += 1;
706
- candidate = path.join(outputDir, `${base}_${suffix}.${extension}`);
756
+ candidate = path.join(root, `${base}_${suffix}${ext}`);
757
+ }
758
+ // Names are built from sanitised parts, but confine defensively anyway.
759
+ if (path.dirname(candidate) !== root) {
760
+ throw new Error('Refusing to write export file outside outputDir');
707
761
  }
708
762
  claimed.add(candidate);
709
763
  return candidate;
710
764
  }
711
765
 
766
+ /**
767
+ * Like fs.existsSync, but a dangling symlink counts as existing (existsSync
768
+ * follows the link and reports false).
769
+ * @param {string} candidate
770
+ * @returns {boolean}
771
+ */
772
+ function pathEntryExists(candidate) {
773
+ try {
774
+ fs.lstatSync(candidate);
775
+ return true;
776
+ } catch {
777
+ return false;
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Claim a unique name in `outputDir` and write `data` to it exclusively. The
783
+ * `wx` flag fails on any existing entry — including a dangling symlink planted
784
+ * after the claim — so an export never overwrites a file or follows a link;
785
+ * on EEXIST the next suffix is claimed instead.
786
+ * @param {string} outputDir - Target directory
787
+ * @param {string} base - Filename without extension (already sanitised)
788
+ * @param {string} extension - Extension without a leading dot ('' for none)
789
+ * @param {Set<string>} claimed - Paths already claimed by this export
790
+ * @param {string|Buffer} data - File contents
791
+ * @param {string} [encoding] - Encoding for string data
792
+ * @returns {string} - Absolute path actually written
793
+ */
794
+ function writeClaimedFile(outputDir, base, extension, claimed, data, encoding) {
795
+ for (let attempt = 0; attempt < 1000; attempt++) {
796
+ const candidate = claimUniquePath(outputDir, base, extension, claimed);
797
+ try {
798
+ fs.writeFileSync(candidate, data, { encoding, flag: 'wx' });
799
+ return candidate;
800
+ } catch (error) {
801
+ if (error.code !== 'EEXIST') throw error;
802
+ }
803
+ }
804
+ throw new Error(`Too many files named ${base} in ${outputDir}`);
805
+ }
806
+
807
+ /**
808
+ * Short, filesystem-safe tag for a message id. Graph ids in one mailbox share
809
+ * a long common prefix and are caller-supplied, so neither a raw prefix nor
810
+ * the id itself is usable in a filename.
811
+ * @param {string} emailId
812
+ * @returns {string} - 8 hex characters
813
+ */
814
+ function messageTag(emailId) {
815
+ return crypto
816
+ .createHash('sha256')
817
+ .update(String(emailId))
818
+ .digest('hex')
819
+ .slice(0, 8);
820
+ }
821
+
712
822
  /**
713
823
  * Split a filename into base and extension for collision-safe claiming.
714
824
  * @param {string} name - Sanitised filename, possibly with an extension
@@ -2,7 +2,8 @@
2
2
  * Email folder utilities
3
3
  */
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
- const { resolveFolder } = require('../folder/resolve');
5
+ const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
6
+ const { buildMailboxPrefix } = require('../utils/mailbox');
6
7
 
7
8
  /**
8
9
  * Cache of folder information to reduce API calls
@@ -46,31 +47,53 @@ const WELL_KNOWN_FOLDERS = {
46
47
  outbox: 'me/mailFolders/outbox/messages',
47
48
  };
48
49
 
50
+ /**
51
+ * Re-point a `me/...` well-known endpoint at a different mailbox.
52
+ * @param {string} endpoint - A WELL_KNOWN_FOLDERS value
53
+ * @param {string} prefix - `me` or `users/{email}`
54
+ * @returns {string}
55
+ */
56
+ function scope(endpoint, prefix) {
57
+ return prefix === 'me' ? endpoint : endpoint.replace(/^me\//, `${prefix}/`);
58
+ }
59
+
49
60
  /**
50
61
  * Resolve a folder name to its endpoint path
51
62
  * @param {string} accessToken - Access token
52
63
  * @param {string} folderName - Folder name to resolve
64
+ * @param {string|null} [mailbox] - Shared mailbox email, or null for the signed-in user
53
65
  * @returns {Promise<string>} - Resolved endpoint path
54
66
  */
55
- async function resolveFolderPath(accessToken, folderName) {
67
+ async function resolveFolderPath(accessToken, folderName, mailbox = null) {
68
+ const prefix = buildMailboxPrefix(mailbox);
69
+
56
70
  // Default to inbox if no folder specified
57
71
  if (!folderName) {
58
- return WELL_KNOWN_FOLDERS.inbox;
72
+ return scope(WELL_KNOWN_FOLDERS.inbox, prefix);
59
73
  }
60
74
 
61
75
  // Check if it's a well-known folder (case-insensitive)
62
76
  const lowerFolderName = folderName.toLowerCase();
63
77
  if (WELL_KNOWN_FOLDERS[lowerFolderName]) {
64
78
  console.error(`Using well-known folder path for "${folderName}"`);
65
- return WELL_KNOWN_FOLDERS[lowerFolderName];
79
+ return scope(WELL_KNOWN_FOLDERS[lowerFolderName], prefix);
80
+ }
81
+
82
+ // A raw folder ID is used as-is, as conversations/export did before name
83
+ // resolution was added (resolving it as a display name would fail).
84
+ if (looksLikeFolderId(folderName)) {
85
+ return `${prefix}/mailFolders/${folderName.trim()}/messages`;
66
86
  }
67
87
 
68
88
  try {
69
89
  // Path-aware resolution: supports nested folders ("Parent/Child"),
70
90
  // case-insensitive names, and reports ambiguity — the same shared resolver
71
91
  // 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`;
92
+ const resolved = await resolveFolder(accessToken, {
93
+ name: folderName,
94
+ mailbox,
95
+ });
96
+ const path = `${prefix}/mailFolders/${resolved.id}/messages`;
74
97
  console.error(`Resolved folder "${folderName}" to path: ${path}`);
75
98
  return path;
76
99
  } catch (error) {
package/email/headers.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  const { callGraphAPI } = require('../utils/graph-api');
8
8
  const { ensureAuthenticated } = require('../auth');
9
+ const { buildMailboxPrefix } = require('../utils/mailbox');
9
10
 
10
11
  /**
11
12
  * Important headers to highlight (in order of relevance)
@@ -158,6 +159,9 @@ async function handleGetEmailHeaders(args) {
158
159
  const groupByType = args.groupByType || false;
159
160
  const importantOnly = args.importantOnly || false;
160
161
  const raw = args.raw || false;
162
+ // Shared-mailbox message IDs aren't resolvable under /me — route to the
163
+ // owning mailbox when a sharedMailbox/email is supplied.
164
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
161
165
 
162
166
  if (!emailId) {
163
167
  return {
@@ -187,7 +191,7 @@ async function handleGetEmailHeaders(args) {
187
191
  'sentDateTime',
188
192
  ].join(',');
189
193
 
190
- const endpoint = `me/messages/${emailId}`;
194
+ const endpoint = `${prefix}/messages/${emailId}`;
191
195
  const queryParams = {
192
196
  $select: selectFields,
193
197
  };