@littlebearapps/outlook-assistant 3.12.0 → 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 +8 -0
- package/README.md +8 -3
- package/advanced/index.js +80 -36
- 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 +33 -10
- package/calendar/list.js +10 -19
- package/calendar/update.js +65 -33
- package/config.js +30 -0
- package/contacts/index.js +2 -1
- package/email/attachments.js +7 -35
- package/email/conversations.js +155 -88
- package/email/delta.js +29 -9
- package/email/draft.js +66 -9
- package/email/export.js +5 -79
- package/email/folder-utils.js +0 -123
- package/email/index.js +12 -10
- package/email/search.js +9 -4
- package/folder/index.js +1 -1
- package/folder/resolve.js +3 -2
- package/index.js +9 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/rules/index.js +3 -3
- package/rules/rule-builder.js +61 -16
- package/utils/datetime.js +170 -0
- package/utils/graph-api.js +324 -218
- 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/delta.js
CHANGED
|
@@ -63,19 +63,39 @@ function mailboxesConflict(tokenMailbox, prefix) {
|
|
|
63
63
|
return isAddress(token) && isAddress(target);
|
|
64
64
|
}
|
|
65
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
|
+
}
|
|
84
|
+
|
|
66
85
|
/**
|
|
67
86
|
* List emails delta handler - incremental sync
|
|
68
87
|
* @param {object} args - Tool arguments
|
|
69
88
|
* @param {string} [args.folder] - Folder to sync (default: inbox)
|
|
70
89
|
* @param {string} [args.deltaToken] - Token from previous delta call (omit for initial sync)
|
|
71
|
-
* @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.
|
|
72
92
|
* @param {string} [args.outputVerbosity] - Output detail level
|
|
73
93
|
* @returns {object} - MCP response with emails, deltaToken, and change summary
|
|
74
94
|
*/
|
|
75
95
|
async function handleListEmailsDelta(args) {
|
|
76
96
|
const folder = args.folder || 'inbox';
|
|
77
97
|
const deltaToken = args.deltaToken;
|
|
78
|
-
const maxResults =
|
|
98
|
+
const maxResults = clampPageSize(args.maxResults);
|
|
79
99
|
const verbosity = args.outputVerbosity || 'standard';
|
|
80
100
|
// Optional: scope the delta sync to a shared/delegated mailbox rather than
|
|
81
101
|
// the signed-in account. Accepts a custom/localized folder name or path.
|
|
@@ -123,19 +143,19 @@ async function handleListEmailsDelta(args) {
|
|
|
123
143
|
: { name: folder, mailbox: sharedMailbox }
|
|
124
144
|
);
|
|
125
145
|
endpoint = `${prefix}/mailFolders/${resolved.id}/messages/delta`;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
$top: maxResults.toString(),
|
|
129
|
-
};
|
|
146
|
+
// No `$top`: on messages/delta it caps the whole sync, not the page.
|
|
147
|
+
queryParams = { $select: getEmailFields('delta') };
|
|
130
148
|
}
|
|
131
149
|
|
|
132
|
-
// 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.
|
|
133
152
|
const response = await callGraphAPI(
|
|
134
153
|
accessToken,
|
|
135
154
|
'GET',
|
|
136
155
|
endpoint,
|
|
137
156
|
null,
|
|
138
|
-
|
|
157
|
+
queryParams,
|
|
158
|
+
{ Prefer: `odata.maxpagesize=${maxResults}` }
|
|
139
159
|
);
|
|
140
160
|
|
|
141
161
|
// Process results
|
|
@@ -237,7 +257,7 @@ async function handleListEmailsDelta(args) {
|
|
|
237
257
|
// Pagination info
|
|
238
258
|
if (hasMoreChanges) {
|
|
239
259
|
resultText += `\n### More Pages Available\n`;
|
|
240
|
-
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`;
|
|
241
261
|
}
|
|
242
262
|
|
|
243
263
|
// Token (delta or continuation)
|
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
|
@@ -18,7 +18,9 @@ const {
|
|
|
18
18
|
const { getEmailFields } = require('../utils/field-presets');
|
|
19
19
|
const { resolveFolderPath } = require('./folder-utils');
|
|
20
20
|
const { buildMailboxPrefix } = require('../utils/mailbox');
|
|
21
|
+
const { quoteSearchPhrase } = require('../utils/odata-helpers');
|
|
21
22
|
const { safeAttachmentFilename } = require('./attachments');
|
|
23
|
+
const { writeClaimedFile } = require('../utils/safe-write');
|
|
22
24
|
|
|
23
25
|
// Export format constants
|
|
24
26
|
const EXPORT_FORMATS = {
|
|
@@ -336,7 +338,7 @@ async function handleBatchExportEmails(args) {
|
|
|
336
338
|
outputDir,
|
|
337
339
|
`batch_export_${timestamp}`,
|
|
338
340
|
'csv',
|
|
339
|
-
|
|
341
|
+
null,
|
|
340
342
|
csvContent,
|
|
341
343
|
'utf8'
|
|
342
344
|
);
|
|
@@ -513,7 +515,7 @@ async function searchEmailsForExport(accessToken, query, mailbox = null) {
|
|
|
513
515
|
searchParts.push(`subject:${query.subject}`);
|
|
514
516
|
}
|
|
515
517
|
if (searchParts.length > 0) {
|
|
516
|
-
params.$search =
|
|
518
|
+
params.$search = quoteSearchPhrase(searchParts.join(' '));
|
|
517
519
|
delete params.$orderby; // Can't combine $search with $orderby
|
|
518
520
|
}
|
|
519
521
|
|
|
@@ -694,7 +696,7 @@ async function saveAttachments(
|
|
|
694
696
|
outputDir,
|
|
695
697
|
`${messageTag(emailId)}_${base}`,
|
|
696
698
|
extension,
|
|
697
|
-
claimedPaths
|
|
699
|
+
claimedPaths,
|
|
698
700
|
buffer
|
|
699
701
|
);
|
|
700
702
|
saved.push({
|
|
@@ -728,82 +730,6 @@ function filenameTimestamp(isoDateTime) {
|
|
|
728
730
|
return parsed.toISOString().slice(0, 19).replace(/[:.]/g, '-');
|
|
729
731
|
}
|
|
730
732
|
|
|
731
|
-
/**
|
|
732
|
-
* Claim a not-yet-used path in `outputDir`, appending `_2`, `_3`, ... until the
|
|
733
|
-
* name is free both on disk and among the paths already claimed in this batch.
|
|
734
|
-
*
|
|
735
|
-
* Silent overwrite is the dangerous part of the collision defect: the exporter
|
|
736
|
-
* reported `Successful N / Failed 0` while messages vanished. Never overwrite —
|
|
737
|
-
* disambiguate instead, and let the caller reconcile via the manifest.
|
|
738
|
-
*
|
|
739
|
-
* The claim is synchronous, so it is atomic with respect to the event loop and
|
|
740
|
-
* safe under the batch exporter's 4-way concurrency even though the write
|
|
741
|
-
* itself happens after an await.
|
|
742
|
-
*
|
|
743
|
-
* @param {string} outputDir - Target directory
|
|
744
|
-
* @param {string} base - Filename without extension
|
|
745
|
-
* @param {string} extension - Extension without a leading dot
|
|
746
|
-
* @param {Set<string>} claimed - Paths already claimed by this batch
|
|
747
|
-
* @returns {string} - An unused absolute path, now claimed
|
|
748
|
-
*/
|
|
749
|
-
function claimUniquePath(outputDir, base, extension, claimed) {
|
|
750
|
-
const root = path.resolve(outputDir);
|
|
751
|
-
const ext = extension ? `.${extension}` : '';
|
|
752
|
-
let candidate = path.join(root, `${base}${ext}`);
|
|
753
|
-
let suffix = 1;
|
|
754
|
-
while (claimed.has(candidate) || pathEntryExists(candidate)) {
|
|
755
|
-
suffix += 1;
|
|
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');
|
|
761
|
-
}
|
|
762
|
-
claimed.add(candidate);
|
|
763
|
-
return candidate;
|
|
764
|
-
}
|
|
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
733
|
/**
|
|
808
734
|
* Short, filesystem-safe tag for a message id. Graph ids in one mailbox share
|
|
809
735
|
* a long common prefix and are caller-supplied, so neither a raw prefix nor
|
package/email/folder-utils.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Email folder utilities
|
|
3
3
|
*/
|
|
4
|
-
const { callGraphAPI } = require('../utils/graph-api');
|
|
5
4
|
const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
|
|
6
5
|
const { buildMailboxPrefix } = require('../utils/mailbox');
|
|
7
6
|
|
|
@@ -111,129 +110,7 @@ async function resolveFolderPath(accessToken, folderName, mailbox = null) {
|
|
|
111
110
|
}
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
/**
|
|
115
|
-
* Get the ID of a mail folder by its name
|
|
116
|
-
* @param {string} accessToken - Access token
|
|
117
|
-
* @param {string} folderName - Name of the folder to find
|
|
118
|
-
* @returns {Promise<string|null>} - Folder ID or null if not found
|
|
119
|
-
*/
|
|
120
|
-
async function getFolderIdByName(accessToken, folderName) {
|
|
121
|
-
try {
|
|
122
|
-
// First try with exact match filter
|
|
123
|
-
console.error(`Looking for folder with name "${folderName}"`);
|
|
124
|
-
const response = await callGraphAPI(
|
|
125
|
-
accessToken,
|
|
126
|
-
'GET',
|
|
127
|
-
'me/mailFolders',
|
|
128
|
-
null,
|
|
129
|
-
{ $filter: `displayName eq '${folderName}'` }
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
if (response.value && response.value.length > 0) {
|
|
133
|
-
console.error(
|
|
134
|
-
`Found folder "${folderName}" with ID: ${response.value[0].id}`
|
|
135
|
-
);
|
|
136
|
-
return response.value[0].id;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// If exact match fails, try to get all folders and do a case-insensitive comparison
|
|
140
|
-
console.error(
|
|
141
|
-
`No exact match found for "${folderName}", trying case-insensitive search`
|
|
142
|
-
);
|
|
143
|
-
const allFoldersResponse = await callGraphAPI(
|
|
144
|
-
accessToken,
|
|
145
|
-
'GET',
|
|
146
|
-
'me/mailFolders',
|
|
147
|
-
null,
|
|
148
|
-
{ $top: 100 }
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
if (allFoldersResponse.value) {
|
|
152
|
-
const lowerFolderName = folderName.toLowerCase();
|
|
153
|
-
const matchingFolder = allFoldersResponse.value.find(
|
|
154
|
-
(folder) => folder.displayName.toLowerCase() === lowerFolderName
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
if (matchingFolder) {
|
|
158
|
-
console.error(
|
|
159
|
-
`Found case-insensitive match for "${folderName}" with ID: ${matchingFolder.id}`
|
|
160
|
-
);
|
|
161
|
-
return matchingFolder.id;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
console.error(`No folder found matching "${folderName}"`);
|
|
166
|
-
return null;
|
|
167
|
-
} catch (error) {
|
|
168
|
-
console.error(`Error finding folder "${folderName}": ${error.message}`);
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Get all mail folders
|
|
175
|
-
* @param {string} accessToken - Access token
|
|
176
|
-
* @returns {Promise<Array>} - Array of folder objects
|
|
177
|
-
*/
|
|
178
|
-
async function getAllFolders(accessToken) {
|
|
179
|
-
try {
|
|
180
|
-
// Get top-level folders
|
|
181
|
-
const response = await callGraphAPI(
|
|
182
|
-
accessToken,
|
|
183
|
-
'GET',
|
|
184
|
-
'me/mailFolders',
|
|
185
|
-
null,
|
|
186
|
-
{
|
|
187
|
-
$top: 100,
|
|
188
|
-
$select:
|
|
189
|
-
'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount',
|
|
190
|
-
}
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
if (!response.value) {
|
|
194
|
-
return [];
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Get child folders for folders with children
|
|
198
|
-
const foldersWithChildren = response.value.filter(
|
|
199
|
-
(f) => f.childFolderCount > 0
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
const childFolderPromises = foldersWithChildren.map(async (folder) => {
|
|
203
|
-
try {
|
|
204
|
-
const childResponse = await callGraphAPI(
|
|
205
|
-
accessToken,
|
|
206
|
-
'GET',
|
|
207
|
-
`me/mailFolders/${folder.id}/childFolders`,
|
|
208
|
-
null,
|
|
209
|
-
{
|
|
210
|
-
$select:
|
|
211
|
-
'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount',
|
|
212
|
-
}
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
return childResponse.value || [];
|
|
216
|
-
} catch (error) {
|
|
217
|
-
console.error(
|
|
218
|
-
`Error getting child folders for "${folder.displayName}": ${error.message}`
|
|
219
|
-
);
|
|
220
|
-
return [];
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
const childFolders = await Promise.all(childFolderPromises);
|
|
225
|
-
|
|
226
|
-
// Combine top-level folders and all child folders
|
|
227
|
-
return [...response.value, ...childFolders.flat()];
|
|
228
|
-
} catch (error) {
|
|
229
|
-
console.error(`Error getting all folders: ${error.message}`);
|
|
230
|
-
return [];
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
113
|
module.exports = {
|
|
235
114
|
WELL_KNOWN_FOLDERS,
|
|
236
115
|
resolveFolderPath,
|
|
237
|
-
getFolderIdByName,
|
|
238
|
-
getAllFolders,
|
|
239
116
|
};
|
package/email/index.js
CHANGED
|
@@ -48,7 +48,7 @@ const emailTools = [
|
|
|
48
48
|
deltaMode: {
|
|
49
49
|
type: 'boolean',
|
|
50
50
|
description:
|
|
51
|
-
'Enable delta sync mode. Returns only changes since last sync. Use deltaToken for subsequent calls. Honors `sharedMailbox`/`email` (and custom `folder` paths) to sync within a shared/delegated mailbox.',
|
|
51
|
+
'Enable delta sync mode. Returns only changes since last sync. Use deltaToken for subsequent calls; an initial sync larger than `maxResults` arrives over several pages, each returning a continuation token to pass back until a delta token is returned. Honors `sharedMailbox`/`email` (and custom `folder` paths) to sync within a shared/delegated mailbox.',
|
|
52
52
|
},
|
|
53
53
|
internetMessageId: {
|
|
54
54
|
type: 'string',
|
|
@@ -58,7 +58,7 @@ const emailTools = [
|
|
|
58
58
|
conversationId: {
|
|
59
59
|
type: 'string',
|
|
60
60
|
description:
|
|
61
|
-
'Get
|
|
61
|
+
'Get the messages in a conversation thread by conversationId, oldest first (up to 100; a longer thread is marked truncated — use `export target=conversation` for up to 1000). Honors `sharedMailbox`/`email` to thread within a shared/delegated mailbox.',
|
|
62
62
|
},
|
|
63
63
|
groupByConversation: {
|
|
64
64
|
type: 'boolean',
|
|
@@ -74,7 +74,7 @@ const emailTools = [
|
|
|
74
74
|
searchExpression: {
|
|
75
75
|
type: 'string',
|
|
76
76
|
description:
|
|
77
|
-
'Raw Microsoft Graph `$search` expression for advanced server-side search, e.g. `subject:"invoice"`, `from:github.com`, or `foo OR bar`. Quote your own phrases; a single bare token is auto-quoted. Pair with `searchAllFolders: true` for cross-folder search. Bypasses other search params. NOTE: personal Outlook.com accounts reject field-scoped `$search` outright; since v3.10.0 recognised `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried automatically (a `subject:` term becomes a substring match, so it is close but not identical) (reported as strategy `raw-kql-translated`). Expressions that cannot be translated exactly — free text, `AND`/`OR`, unknown prefixes — are not retried, so use `query` for those there. RELEVANCE, NOT RECENCY: an untranslated expression is answered by Graph `$search` over the whole message including the body, ranked by relevance and not sorted by date, so top hits can look unrelated to a caller expecting a subject match. `query` is the more predictable choice for a term you expect in a subject line; `searchExpression` is the one that reaches body text.',
|
|
77
|
+
'Raw Microsoft Graph `$search` expression for advanced server-side search, e.g. `subject:"invoice"`, `from:github.com`, or `foo OR bar`. Quote your own phrases, escaping any `"` or `\\` inside them with a backslash; a single bare token is auto-quoted and escaped for you. Pair with `searchAllFolders: true` for cross-folder search. Bypasses other search params. NOTE: personal Outlook.com accounts reject field-scoped `$search` outright; since v3.10.0 recognised `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried automatically (a `subject:` term becomes a substring match, so it is close but not identical) (reported as strategy `raw-kql-translated`). Expressions that cannot be translated exactly — free text, `AND`/`OR`, unknown prefixes — are not retried, so use `query` for those there. RELEVANCE, NOT RECENCY: an untranslated expression is answered by Graph `$search` over the whole message including the body, ranked by relevance and not sorted by date, so top hits can look unrelated to a caller expecting a subject match. `query` is the more predictable choice for a term you expect in a subject line; `searchExpression` is the one that reaches body text.',
|
|
78
78
|
},
|
|
79
79
|
kqlQuery: {
|
|
80
80
|
type: 'string',
|
|
@@ -147,7 +147,7 @@ const emailTools = [
|
|
|
147
147
|
maxResults: {
|
|
148
148
|
type: 'number',
|
|
149
149
|
description:
|
|
150
|
-
'
|
|
150
|
+
'Delta sync page size (deltaMode only): 1-200, default 100, sent to Graph as the `Prefer: odata.maxpagesize` header. It sizes each page, not the whole sync: while a page returns a continuation token, keep calling with that token until a delta token is returned, and pass the same `maxResults` on every page (an omitted value means 100).',
|
|
151
151
|
},
|
|
152
152
|
// Conversation params
|
|
153
153
|
includeHeaders: {
|
|
@@ -327,7 +327,7 @@ const emailTools = [
|
|
|
327
327
|
{
|
|
328
328
|
name: 'draft',
|
|
329
329
|
description:
|
|
330
|
-
'Full draft lifecycle for review-before-send workflows (destructive: covers `send` and `delete`). action=`create` saves a new draft in the Drafts folder and returns its id (use `dryRun: true` to preview without saving; `checkRecipients: true` runs mail-tips first). action=`update` patches an existing draft by `id` (only fields passed are changed). action=`send` dispatches an existing draft — shares the rate limit with `send-email`. action=`delete`
|
|
330
|
+
'Full draft lifecycle for review-before-send workflows (destructive: covers `send` and `delete`). action=`create` saves a new draft in the Drafts folder and returns its id (use `dryRun: true` to preview without saving; `checkRecipients: true` runs mail-tips first). action=`update` patches an existing draft by `id` (only fields passed are changed). action=`send` dispatches an existing draft — shares the rate limit with `send-email`. action=`delete` deletes a draft: it skips Deleted Items and goes to Recoverable Items (restorable for a limited time, depending on your account, via "Recover deleted items" in Outlook). update/send/delete refuse any `id` that is not an unsent draft (received or sent messages are never changed). action=`reply`/`reply-all` creates a reply draft from a message `id` (use `comment` to prepend text — mutually exclusive with `body`). action=`forward` creates a forward draft (requires `id` and `to`). Recipient allowlist applies to create/update/forward. Returns the draft object on create/update/reply/forward; status confirmation on send/delete.',
|
|
331
331
|
annotations: {
|
|
332
332
|
title: 'Draft Operations',
|
|
333
333
|
readOnlyHint: false,
|
|
@@ -354,7 +354,7 @@ const emailTools = [
|
|
|
354
354
|
id: {
|
|
355
355
|
type: 'string',
|
|
356
356
|
description:
|
|
357
|
-
'Draft or message ID. Required for update/send/delete/reply/reply-all/forward.',
|
|
357
|
+
'Draft or message ID. Required for update/send/delete/reply/reply-all/forward. update/send/delete need a draft ID; reply/reply-all/forward take any message ID.',
|
|
358
358
|
},
|
|
359
359
|
to: {
|
|
360
360
|
type: 'string',
|
|
@@ -406,7 +406,7 @@ const emailTools = [
|
|
|
406
406
|
{
|
|
407
407
|
name: 'update-email',
|
|
408
408
|
description:
|
|
409
|
-
'Update message state without modifying content (idempotent — safe to retry). action=`mark-read`/`mark-unread` toggles the `isRead` flag on a single message by `id`. action=`flag` sets a follow-up flag with optional `dueDateTime`/`startDateTime` (ISO 8601). action=`unflag` clears the flag. action=`complete` marks the flag as done. Flag/unflag/complete accept either `id` (single) or `ids` (batch array) — batch
|
|
409
|
+
'Update message state without modifying content (idempotent — safe to retry). action=`mark-read`/`mark-unread` toggles the `isRead` flag on a single message by `id`. action=`flag` sets a follow-up flag with optional `dueDateTime`/`startDateTime` (ISO 8601 with a time: a value with `Z` or a ±hh:mm offset is kept as that exact instant; a value without one is read in the configured default timezone (OUTLOOK_DEFAULT_TIMEZONE); date-only or unparseable values are refused before any change). With only `dueDateTime`, the start defaults to 09:00 on the due date in the default timezone, or to the due time if that is earlier. action=`unflag` clears the flag. action=`complete` marks the flag as done. Flag/unflag/complete accept either `id` (single) or `ids` (batch array) — messages in a batch are updated one at a time (one PATCH each, not Graph `$batch`). Pass `sharedMailbox` (or alias `email`) to update messages in a shared/delegated mailbox instead of the signed-in account (requires Mail.ReadWrite.Shared + delegate access). Returns status confirmation per message.',
|
|
410
410
|
annotations: {
|
|
411
411
|
title: 'Update Email',
|
|
412
412
|
readOnlyHint: false,
|
|
@@ -436,11 +436,13 @@ const emailTools = [
|
|
|
436
436
|
// Flag params
|
|
437
437
|
dueDateTime: {
|
|
438
438
|
type: 'string',
|
|
439
|
-
description:
|
|
439
|
+
description:
|
|
440
|
+
'Due date/time for follow-up (action=flag). ISO 8601 with a time: "2026-03-01T09:00:00Z" or "2026-03-01T09:00:00+10:00" is that exact instant; "2026-03-01T09:00:00" (no zone) is read in the default timezone (OUTLOOK_DEFAULT_TIMEZONE).',
|
|
440
441
|
},
|
|
441
442
|
startDateTime: {
|
|
442
443
|
type: 'string',
|
|
443
|
-
description:
|
|
444
|
+
description:
|
|
445
|
+
'Start date/time for follow-up (action=flag), same format as dueDateTime. Defaults to 09:00 on the due date in the default timezone (capped at the due time) when only dueDateTime is given.',
|
|
444
446
|
},
|
|
445
447
|
sharedMailbox: {
|
|
446
448
|
type: 'string',
|
|
@@ -575,7 +577,7 @@ const emailTools = [
|
|
|
575
577
|
{
|
|
576
578
|
name: 'export',
|
|
577
579
|
description:
|
|
578
|
-
'Export emails to file formats for archival, forensics, or programmatic processing. target=`message` (default) exports a single email by `id` to `savePath` — accepts `mime`/`eml`/`markdown`/`json`/`csv`. target=`messages` batch-exports either an explicit `emailIds` array or messages matching `searchQuery` (or `query` shortcut) into `outputDir` — accepts `markdown`/`json`/`csv`. target=`conversation` exports a full thread by `conversationId` into `outputDir` (chronological by default; pass `order: "reverse"` for newest-first) — accepts `eml`/`mbox`/`markdown`/`json`/`html`/`csv`. target=`mime` returns raw RFC-822 MIME bytes for `id` (use `headersOnly` for just headers, `base64` for encoded transport, `maxSize` to cap at default 1MB). All targets accept `sharedMailbox` (alias `email`) to export from a shared/delegated mailbox instead of the signed-in account — pass it whenever the id(s)/conversationId/searchQuery come from a shared mailbox, or exports fail with 404 ErrorInvalidMailboxItemId. `includeAttachments` defaults to true for single-message exports and false for batch. Format support varies by target — see the format param enum.',
|
|
580
|
+
'Export emails to file formats for archival, forensics, or programmatic processing. target=`message` (default) exports a single email by `id` to `savePath` — accepts `mime`/`eml`/`markdown`/`json`/`csv`. target=`messages` batch-exports either an explicit `emailIds` array or messages matching `searchQuery` (or `query` shortcut) into `outputDir` — accepts `markdown`/`json`/`csv`. target=`conversation` exports a full thread (up to 1000 messages) by `conversationId` into `outputDir` (chronological by default; pass `order: "reverse"` for newest-first) — accepts `eml`/`mbox`/`markdown`/`json`/`html`/`csv`. target=`mime` returns raw RFC-822 MIME bytes for `id` (use `headersOnly` for just headers, `base64` for encoded transport, `maxSize` to cap at default 1MB). All targets accept `sharedMailbox` (alias `email`) to export from a shared/delegated mailbox instead of the signed-in account — pass it whenever the id(s)/conversationId/searchQuery come from a shared mailbox, or exports fail with 404 ErrorInvalidMailboxItemId. `includeAttachments` defaults to true for single-message exports and false for batch. Format support varies by target — see the format param enum.',
|
|
579
581
|
annotations: {
|
|
580
582
|
title: 'Export Emails',
|
|
581
583
|
readOnlyHint: false,
|
package/email/search.js
CHANGED
|
@@ -14,7 +14,11 @@ const {
|
|
|
14
14
|
DEFAULT_LIMITS,
|
|
15
15
|
} = require('../utils/response-formatter');
|
|
16
16
|
const { getEmailFields } = require('../utils/field-presets');
|
|
17
|
-
const {
|
|
17
|
+
const {
|
|
18
|
+
escapeODataString,
|
|
19
|
+
escapeSearchPhrase,
|
|
20
|
+
quoteSearchPhrase,
|
|
21
|
+
} = require('../utils/odata-helpers');
|
|
18
22
|
|
|
19
23
|
// Upper bound on how many recent messages the client-side fallback scans
|
|
20
24
|
// before giving up. Deliberately DECOUPLED from the requested result count so
|
|
@@ -328,12 +332,13 @@ async function progressiveSearch(
|
|
|
328
332
|
trimmedKql.includes(':') || /\s/.test(trimmedKql);
|
|
329
333
|
// Already-quoted phrases and KQL-looking expressions (field syntax
|
|
330
334
|
// or multi-word) are passed through as-is; only bare single tokens
|
|
331
|
-
// are wrapped so Graph treats them as phrase searches.
|
|
335
|
+
// are wrapped so Graph treats them as phrase searches. The wrapped
|
|
336
|
+
// token's own `"` and `\` are escaped so they cannot end the phrase. (#251)
|
|
332
337
|
let kqlForSearch;
|
|
333
338
|
if (alreadyQuoted || looksLikeExpression) {
|
|
334
339
|
kqlForSearch = trimmedKql;
|
|
335
340
|
} else {
|
|
336
|
-
kqlForSearch = `"${trimmedKql}"`;
|
|
341
|
+
kqlForSearch = `"${escapeSearchPhrase(trimmedKql)}"`;
|
|
337
342
|
}
|
|
338
343
|
|
|
339
344
|
// Graph rejects field-scoped expressions outright on personal accounts
|
|
@@ -1212,7 +1217,7 @@ function buildSearchParams(searchTerms, filterTerms, count, selectFields) {
|
|
|
1212
1217
|
|
|
1213
1218
|
// Handle search terms - use $search only for free-text query
|
|
1214
1219
|
if (searchTerms.query) {
|
|
1215
|
-
params.$search =
|
|
1220
|
+
params.$search = quoteSearchPhrase(searchTerms.query);
|
|
1216
1221
|
}
|
|
1217
1222
|
|
|
1218
1223
|
// Build filter conditions array - use $filter for structured fields (more reliable)
|
package/folder/index.js
CHANGED
|
@@ -12,7 +12,7 @@ const folderTools = [
|
|
|
12
12
|
{
|
|
13
13
|
name: 'folders',
|
|
14
14
|
description:
|
|
15
|
-
"Manage mail folders (tool-level destructiveHint=true because `delete`
|
|
15
|
+
"Manage mail folders (tool-level destructiveHint=true because `delete` removes a folder and its contents; `list` and `stats` are read-only sub-actions despite the annotation). Folders can be addressed by name, by a slash-separated PATH for nested folders (e.g. `Triage/Delete`, `Inbox/Clients/Acme`, case-insensitive), or by explicit ID; `list` output includes each folder's full path and `[id: …]`. A bare name resolves a unique top-level folder first, then searches nested folders (ambiguous names return the candidates — disambiguate with a path or ID). action=`list` (default) returns the folder tree (toggle `includeItemCounts` for unread/total, `includeChildren` for hierarchy). action=`create` makes a new folder under the root, or under `parentFolder` (name/path) / `parentFolderId`, and returns its id. action=`move` relocates emails (`emailIds`) into `targetFolder` (name/path) or `targetFolderId`. action=`stats` returns counts (totalItemCount/unreadItemCount) for `folder` (name/path) or `folderId`, suitable for pagination planning. action=`delete` removes a folder (by `folderName`/path or `folderId`) and its contents — it does not go to Deleted Items, and Graph doesn't document whether it can be restored (some accounts may offer Outlook's \"Recover deleted items\" for a limited time, but don't rely on it), so move out anything you might need first. Every action accepts `sharedMailbox` (alias `email`) to target a shared/delegated mailbox instead of the signed-in account — folder names, paths, and IDs are then resolved inside that mailbox. Protected folders cannot be deleted in any mailbox.",
|
|
16
16
|
annotations: {
|
|
17
17
|
title: 'Mail Folders',
|
|
18
18
|
readOnlyHint: false,
|
package/folder/resolve.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared, path-aware, ambiguity-aware mail-folder resolver. (#216)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Replaced the top-level-only resolvers (`getFolderIdByName` in
|
|
5
5
|
* email/folder-utils.js and `resolveFolderName` in folder/stats.js) that could
|
|
6
|
-
* not address nested folders
|
|
6
|
+
* not address nested folders (manage-rules folder actions use it since #248).
|
|
7
|
+
* Accepts, in priority order:
|
|
7
8
|
* 1. an explicit folder ID (never guessed from a name),
|
|
8
9
|
* 2. a well-known alias (inbox, archive, sent, ...),
|
|
9
10
|
* 3. a folder PATH like "Triage/Delete" or "Inbox/Clients/Acme"
|
package/index.js
CHANGED
|
@@ -35,8 +35,16 @@ Key environment variables:
|
|
|
35
35
|
OUTLOOK_CLIENT_SECRET Client secret VALUE (not the Secret ID)
|
|
36
36
|
OUTLOOK_AUTH_METHOD device-code (default) | browser
|
|
37
37
|
OUTLOOK_AUTH_AUDIENCE common | consumers | organizations | <tenant-guid>
|
|
38
|
-
|
|
38
|
+
OUTLOOK_SHARED_MAILBOX Opt in to shared mailboxes: read | true (work/school only)
|
|
39
39
|
OUTLOOK_ALLOWED_RECIPIENTS Comma-separated recipient allowlist
|
|
40
|
+
OUTLOOK_MAX_EMAILS_PER_SESSION Default cap per session for every rate-limited tool
|
|
41
|
+
(send-email, draft, manage-rules); 0 or unset = no cap
|
|
42
|
+
OUTLOOK_MAX_<TOOL>_PER_SESSION Per-tool cap overriding the default, tool name in upper
|
|
43
|
+
case with _ for -, e.g. OUTLOOK_MAX_SEND_EMAIL_PER_SESSION
|
|
44
|
+
OUTLOOK_DEFAULT_TIMEZONE IANA timezone for event times (default Australia/Melbourne)
|
|
45
|
+
OUTLOOK_IMMUTABLE_IDS Set to "true" for message IDs that survive folder moves
|
|
46
|
+
OUTLOOK_SEARCH_SCAN_LIMIT Local search fallback window (default 500, max 5000)
|
|
47
|
+
OUTLOOK_REQUEST_TIMEOUT_MS Graph request inactivity timeout (default 60000)
|
|
40
48
|
USE_TEST_MODE Set to "true" to run against mock data
|
|
41
49
|
|
|
42
50
|
Documentation: https://github.com/littlebearapps/outlook-assistant`;
|
package/llms.txt
CHANGED
|
@@ -83,6 +83,6 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
|
|
|
83
83
|
- [FAQ](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/faq/faq.md): Frequently asked questions — install, accounts, permissions, tokens, send safety, updates, uninstall (also at <https://littlebearapps.com/help/outlook-assistant/faq/>)
|
|
84
84
|
- [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
|
|
85
85
|
- [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
|
|
86
|
-
- [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.12.0 — opt-in shared-mailbox read and organise support via `sharedMailbox` and `OUTLOOK_SHARED_MAILBOX` (#228), `list-events` `startAfter`/`startBefore`/`subject` filters (#193), token refresh requesting the granted scopes plus `offline_access` (#241), and two security fixes: dot segments in resource paths rejected, `export` writes confined to the output directory. Preceded by v3.11.2 — security release: attachment downloads confined to `outputDir` (GHSA-755c-c45g-69rv) and the access token only ever sent to Microsoft Graph (GHSA-mqfm-wfjq-jxq2); v3.11.1 — search and export correctness (filters combined with dates no longer silently dropped, batch export no longer overwrites same-day reply chains); v3.11.0 — `--version`/`--help` CLI flags (#68) and the `AADSTS7000215` Secret ID vs Value explanation (#69); v3.10.0 — search correctness (#217, #229, #230, #231))
|
|
86
|
+
- [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.12.1 — Graph reliability and correctness fixes: throttling retries, a request inactivity timeout and a 4-request concurrency cap (#244), complete delta paging (#254), a draft-only guard on `draft` update/send/delete (#246), nested folder paths in `manage-rules` (#248), flag dates honouring `Z` and offsets (#247), attendee types kept on `manage-event update` (#249), no "via API" text on decline/cancel (#242), escaped `$search` phrases (#251), and conversation read/export working on personal accounts. Preceded by v3.12.0 — opt-in shared-mailbox read and organise support via `sharedMailbox` and `OUTLOOK_SHARED_MAILBOX` (#228), `list-events` `startAfter`/`startBefore`/`subject` filters (#193), token refresh requesting the granted scopes plus `offline_access` (#241), and two security fixes: dot segments in resource paths rejected, `export` writes confined to the output directory. Preceded by v3.11.2 — security release: attachment downloads confined to `outputDir` (GHSA-755c-c45g-69rv) and the access token only ever sent to Microsoft Graph (GHSA-mqfm-wfjq-jxq2); v3.11.1 — search and export correctness (filters combined with dates no longer silently dropped, batch export no longer overwrites same-day reply chains); v3.11.0 — `--version`/`--help` CLI flags (#68) and the `AADSTS7000215` Secret ID vs Value explanation (#69); v3.10.0 — search correctness (#217, #229, #230, #231))
|
|
87
87
|
- [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.12.x tool description audit, v3.8.x carry-over, v3.13.0+ new Graph APIs)
|
|
88
|
-
- [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy, token handling, and MCP safety controls
|
|
88
|
+
- [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy (report vulnerabilities privately via GitHub private vulnerability reporting; acknowledged within 7 days), token handling, and MCP safety controls
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@littlebearapps/outlook-assistant",
|
|
3
|
-
"version": "3.12.
|
|
3
|
+
"version": "3.12.1",
|
|
4
4
|
"mcpName": "io.github.littlebearapps/outlook-assistant",
|
|
5
5
|
"description": "Outlook Assistant — MCP server with 22 tools for email, calendar, contacts, and settings via Microsoft Graph API",
|
|
6
6
|
"main": "index.js",
|