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