@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.
- package/.env.example +20 -0
- package/README.md +51 -28
- package/advanced/index.js +319 -46
- package/auth/device-code.js +100 -3
- package/auth/token-storage.js +44 -2
- package/auth/tools.js +196 -14
- package/calendar/attendees.js +101 -0
- package/calendar/cancel.js +5 -4
- package/calendar/create.js +15 -4
- package/calendar/decline.js +10 -5
- package/calendar/index.js +51 -10
- package/calendar/list.js +146 -3
- package/calendar/update.js +65 -33
- package/categories/index.js +17 -3
- package/config.js +103 -17
- package/contacts/index.js +2 -1
- package/email/attachments.js +19 -37
- package/email/conversations.js +180 -91
- package/email/delta.js +123 -13
- package/email/draft.js +66 -9
- package/email/export.js +113 -77
- package/email/folder-utils.js +29 -129
- package/email/headers.js +5 -1
- package/email/index.js +76 -19
- 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 +23 -9
- 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 +65 -25
- package/folder/stats.js +11 -5
- package/index.js +9 -1
- package/llms-install.md +28 -9
- package/llms.txt +13 -9
- package/package.json +3 -3
- package/rules/index.js +3 -3
- package/rules/rule-builder.js +61 -16
- package/utils/datetime.js +170 -0
- package/utils/graph-api.js +390 -211
- package/utils/mailbox.js +77 -0
- package/utils/mock-data.js +3 -0
- package/utils/odata-helpers.js +24 -0
- package/utils/safe-write.js +151 -0
- 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
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
|
|
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
|
|
694
|
+
const buffer = Buffer.from(att.contentBytes, 'base64');
|
|
695
|
+
const filePath = writeClaimedFile(
|
|
645
696
|
outputDir,
|
|
646
|
-
`${emailId
|
|
697
|
+
`${messageTag(emailId)}_${base}`,
|
|
647
698
|
extension,
|
|
648
|
-
claimedPaths
|
|
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
|
-
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
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
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
/**
|
package/email/folder-utils.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Email folder utilities
|
|
3
3
|
*/
|
|
4
|
-
const {
|
|
5
|
-
const {
|
|
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, {
|
|
73
|
-
|
|
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
|
};
|