@littlebearapps/outlook-assistant 3.11.1 → 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/.env.example +12 -0
- package/README.md +46 -28
- package/advanced/index.js +239 -10
- package/auth/device-code.js +100 -3
- package/auth/token-storage.js +44 -2
- package/auth/tools.js +196 -14
- package/calendar/index.js +19 -1
- package/calendar/list.js +154 -2
- package/categories/index.js +17 -3
- package/config.js +73 -17
- package/email/attachments.js +79 -5
- package/email/conversations.js +29 -15
- package/email/delta.js +94 -4
- package/email/export.js +164 -54
- package/email/folder-utils.js +29 -6
- package/email/headers.js +5 -1
- package/email/index.js +68 -13
- package/email/list.js +8 -1
- package/email/mark-as-read.js +3 -1
- package/email/mime.js +4 -1
- package/email/read.js +5 -1
- package/email/search.js +14 -5
- package/folder/create.js +11 -4
- package/folder/delete.js +9 -1
- package/folder/index.js +11 -1
- package/folder/list.js +61 -27
- package/folder/move.js +32 -7
- package/folder/resolve.js +62 -23
- package/folder/stats.js +11 -5
- package/llms-install.md +28 -9
- package/llms.txt +12 -8
- package/package.json +5 -5
- package/utils/graph-api.js +109 -3
- package/utils/mailbox.js +77 -0
- package/utils/response-formatter.js +44 -10
package/email/delta.js
CHANGED
|
@@ -8,6 +8,60 @@ 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
|
+
}
|
|
11
65
|
|
|
12
66
|
/**
|
|
13
67
|
* List emails delta handler - incremental sync
|
|
@@ -23,6 +77,10 @@ async function handleListEmailsDelta(args) {
|
|
|
23
77
|
const deltaToken = args.deltaToken;
|
|
24
78
|
const maxResults = Math.min(args.maxResults || 100, 200);
|
|
25
79
|
const verbosity = args.outputVerbosity || 'standard';
|
|
80
|
+
// Optional: scope the delta sync to a shared/delegated mailbox rather than
|
|
81
|
+
// the signed-in account. Accepts a custom/localized folder name or path.
|
|
82
|
+
const sharedMailbox = args.sharedMailbox || args.email || null;
|
|
83
|
+
const prefix = buildMailboxPrefix(sharedMailbox);
|
|
26
84
|
|
|
27
85
|
try {
|
|
28
86
|
const accessToken = await ensureAuthenticated();
|
|
@@ -32,11 +90,39 @@ async function handleListEmailsDelta(args) {
|
|
|
32
90
|
let queryParams = {};
|
|
33
91
|
|
|
34
92
|
if (deltaToken) {
|
|
35
|
-
// Continue from previous sync - use deltaLink directly
|
|
93
|
+
// Continue from previous sync - use deltaLink directly. The token is
|
|
94
|
+
// authoritative: it already encodes the mailbox and folder, so the
|
|
95
|
+
// `folder`/`sharedMailbox` args are ignored. Reject a token from a
|
|
96
|
+
// different mailbox rather than silently syncing the wrong one.
|
|
97
|
+
const tokenMailbox = mailboxFromToken(deltaToken);
|
|
98
|
+
if (tokenMailbox && mailboxesConflict(tokenMailbox, prefix)) {
|
|
99
|
+
return {
|
|
100
|
+
content: [
|
|
101
|
+
{
|
|
102
|
+
type: 'text',
|
|
103
|
+
text:
|
|
104
|
+
`Delta token mailbox mismatch: the token belongs to \`${tokenMailbox}\` but this call targets \`${prefix}\`.\n\n` +
|
|
105
|
+
'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.',
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
36
110
|
endpoint = deltaToken;
|
|
37
111
|
} else {
|
|
38
|
-
// Initial sync - start fresh
|
|
39
|
-
|
|
112
|
+
// Initial sync - start fresh. Resolve the folder (well-known name,
|
|
113
|
+
// nested path, display name, or raw ID) within the target mailbox so
|
|
114
|
+
// custom subfolders work for shared mailboxes too.
|
|
115
|
+
// Resolution failures (not-found / ambiguous) carry their own actionable
|
|
116
|
+
// message; let them propagate to the handler's catch like any other error.
|
|
117
|
+
// A raw folder ID (accepted here before name resolution existed) is
|
|
118
|
+
// treated as an ID, not searched for as a display name.
|
|
119
|
+
const resolved = await resolveFolder(
|
|
120
|
+
accessToken,
|
|
121
|
+
looksLikeFolderId(folder)
|
|
122
|
+
? { id: folder, mailbox: sharedMailbox }
|
|
123
|
+
: { name: folder, mailbox: sharedMailbox }
|
|
124
|
+
);
|
|
125
|
+
endpoint = `${prefix}/mailFolders/${resolved.id}/messages/delta`;
|
|
40
126
|
queryParams = {
|
|
41
127
|
$select: getEmailFields('delta'),
|
|
42
128
|
$top: maxResults.toString(),
|
|
@@ -175,7 +261,11 @@ async function handleListEmailsDelta(args) {
|
|
|
175
261
|
],
|
|
176
262
|
_meta: {
|
|
177
263
|
syncType: isInitialSync ? 'initial' : 'incremental',
|
|
178
|
-
|
|
264
|
+
mailbox: sharedMailbox || 'me',
|
|
265
|
+
// With a token the folder comes from the token, not the `folder` arg
|
|
266
|
+
// (which is ignored) — don't echo a value we didn't use.
|
|
267
|
+
folder: isInitialSync ? folder : null,
|
|
268
|
+
folderSource: isInitialSync ? 'argument' : 'deltaToken',
|
|
179
269
|
itemCount: processedEmails.length,
|
|
180
270
|
hasMoreChanges: hasMoreChanges,
|
|
181
271
|
changesSummary: changesSummary,
|
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
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
|
|
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
|
|
692
|
+
const buffer = Buffer.from(att.contentBytes, 'base64');
|
|
693
|
+
const filePath = writeClaimedFile(
|
|
645
694
|
outputDir,
|
|
646
|
-
`${emailId
|
|
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
|
-
|
|
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) ||
|
|
754
|
+
while (claimed.has(candidate) || pathEntryExists(candidate)) {
|
|
705
755
|
suffix += 1;
|
|
706
|
-
candidate = path.join(
|
|
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
|
package/email/folder-utils.js
CHANGED
|
@@ -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, {
|
|
73
|
-
|
|
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 =
|
|
194
|
+
const endpoint = `${prefix}/messages/${emailId}`;
|
|
191
195
|
const queryParams = {
|
|
192
196
|
$select: selectFields,
|
|
193
197
|
};
|