@littlebearapps/outlook-assistant 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +22 -0
- package/LICENSE +21 -0
- package/README.md +422 -0
- package/advanced/index.js +652 -0
- package/auth/index.js +32 -0
- package/auth/oauth-server.js +233 -0
- package/auth/token-manager.js +105 -0
- package/auth/token-storage.js +359 -0
- package/auth/tools.js +159 -0
- package/calendar/accept.js +72 -0
- package/calendar/cancel.js +72 -0
- package/calendar/create.js +115 -0
- package/calendar/decline.js +72 -0
- package/calendar/delete.js +67 -0
- package/calendar/index.js +130 -0
- package/calendar/list.js +108 -0
- package/categories/index.js +955 -0
- package/config.js +95 -0
- package/contacts/index.js +754 -0
- package/email/attachments.js +365 -0
- package/email/conversations.js +666 -0
- package/email/delta.js +210 -0
- package/email/export.js +572 -0
- package/email/folder-utils.js +192 -0
- package/email/headers.js +344 -0
- package/email/index.js +537 -0
- package/email/list.js +136 -0
- package/email/mark-as-read.js +114 -0
- package/email/mime.js +286 -0
- package/email/read.js +161 -0
- package/email/search.js +628 -0
- package/email/send.js +169 -0
- package/folder/create.js +137 -0
- package/folder/delete.js +108 -0
- package/folder/index.js +112 -0
- package/folder/list.js +289 -0
- package/folder/move.js +186 -0
- package/folder/stats.js +322 -0
- package/index.js +162 -0
- package/llms.txt +76 -0
- package/outlook-auth-server.js +384 -0
- package/package.json +97 -0
- package/rules/create.js +273 -0
- package/rules/index.js +276 -0
- package/rules/list.js +216 -0
- package/settings/index.js +678 -0
- package/utils/field-presets.js +311 -0
- package/utils/graph-api.js +268 -0
- package/utils/mock-data.js +154 -0
- package/utils/odata-helpers.js +33 -0
- package/utils/response-formatter.js +457 -0
- package/utils/safety.js +123 -0
package/email/search.js
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Improved search emails functionality
|
|
3
|
+
*
|
|
4
|
+
* Token-efficient implementation with outputVerbosity support and Markdown formatting.
|
|
5
|
+
*/
|
|
6
|
+
const _config = require('../config'); // Reserved for future use
|
|
7
|
+
const { callGraphAPI, callGraphAPIPaginated } = require('../utils/graph-api');
|
|
8
|
+
const { ensureAuthenticated } = require('../auth');
|
|
9
|
+
const { resolveFolderPath } = require('./folder-utils');
|
|
10
|
+
const {
|
|
11
|
+
formatEmailList,
|
|
12
|
+
VERBOSITY,
|
|
13
|
+
DEFAULT_LIMITS,
|
|
14
|
+
} = require('../utils/response-formatter');
|
|
15
|
+
const { getEmailFields } = require('../utils/field-presets');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Search emails handler
|
|
19
|
+
* @param {object} args - Tool arguments
|
|
20
|
+
* @param {string} [args.folder] - Folder to search (default: inbox)
|
|
21
|
+
* @param {number} [args.count] - Number of results (default: 10, max: 50)
|
|
22
|
+
* @param {string} [args.outputVerbosity] - minimal, standard, or full (default: standard)
|
|
23
|
+
* @param {string} [args.kqlQuery] - Raw KQL query for advanced users
|
|
24
|
+
* @returns {object} - MCP response with Markdown formatted content
|
|
25
|
+
*/
|
|
26
|
+
async function handleSearchEmails(args) {
|
|
27
|
+
const folder = args.folder || 'inbox';
|
|
28
|
+
|
|
29
|
+
// Validate count
|
|
30
|
+
if (args.count !== undefined && args.count < 1) {
|
|
31
|
+
return {
|
|
32
|
+
content: [{ type: 'text', text: 'count must be at least 1.' }],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const requestedCount = args.count ?? DEFAULT_LIMITS.searchEmails; // Default 10
|
|
37
|
+
const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
|
|
38
|
+
const query = args.query || '';
|
|
39
|
+
const from = args.from || '';
|
|
40
|
+
const to = args.to || '';
|
|
41
|
+
const subject = args.subject || '';
|
|
42
|
+
const hasAttachments = args.hasAttachments;
|
|
43
|
+
const unreadOnly = args.unreadOnly;
|
|
44
|
+
const receivedAfter = args.receivedAfter || '';
|
|
45
|
+
const receivedBefore = args.receivedBefore || '';
|
|
46
|
+
const searchAllFolders = args.searchAllFolders || false;
|
|
47
|
+
const kqlQuery = args.kqlQuery || ''; // Raw KQL for advanced users
|
|
48
|
+
|
|
49
|
+
// Select fields based on verbosity
|
|
50
|
+
const selectFields = getEmailFields(
|
|
51
|
+
verbosity === VERBOSITY.FULL ? 'search' : 'list'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// Get access token
|
|
56
|
+
const accessToken = await ensureAuthenticated();
|
|
57
|
+
|
|
58
|
+
// Determine endpoint - search all folders or specific folder
|
|
59
|
+
let endpoint;
|
|
60
|
+
if (searchAllFolders) {
|
|
61
|
+
endpoint = 'me/messages';
|
|
62
|
+
console.error('Searching across all mail folders');
|
|
63
|
+
} else {
|
|
64
|
+
endpoint = await resolveFolderPath(accessToken, folder);
|
|
65
|
+
console.error(`Using endpoint: ${endpoint} for folder: ${folder}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Execute progressive search with pagination
|
|
69
|
+
const response = await progressiveSearch(
|
|
70
|
+
endpoint,
|
|
71
|
+
accessToken,
|
|
72
|
+
{ query, from, to, subject, kqlQuery },
|
|
73
|
+
{ hasAttachments, unreadOnly, receivedAfter, receivedBefore },
|
|
74
|
+
requestedCount,
|
|
75
|
+
selectFields
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return formatSearchResults(response, folder, verbosity);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// Handle authentication errors
|
|
81
|
+
if (error.message === 'Authentication required') {
|
|
82
|
+
return {
|
|
83
|
+
content: [
|
|
84
|
+
{
|
|
85
|
+
type: 'text',
|
|
86
|
+
text: "Authentication required. Please use the 'authenticate' tool first.",
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// General error response
|
|
93
|
+
return {
|
|
94
|
+
content: [
|
|
95
|
+
{
|
|
96
|
+
type: 'text',
|
|
97
|
+
text: `Error searching emails: ${error.message}`,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Execute a search with progressively simpler fallback strategies
|
|
106
|
+
* @param {string} endpoint - API endpoint
|
|
107
|
+
* @param {string} accessToken - Access token
|
|
108
|
+
* @param {object} searchTerms - Search terms (query, from, to, subject, kqlQuery)
|
|
109
|
+
* @param {object} filterTerms - Filter terms (hasAttachments, unreadOnly)
|
|
110
|
+
* @param {number} maxCount - Maximum number of results to retrieve
|
|
111
|
+
* @param {string} selectFields - Comma-separated field list for $select
|
|
112
|
+
* @returns {Promise<object>} - Search results
|
|
113
|
+
*/
|
|
114
|
+
async function progressiveSearch(
|
|
115
|
+
endpoint,
|
|
116
|
+
accessToken,
|
|
117
|
+
searchTerms,
|
|
118
|
+
filterTerms,
|
|
119
|
+
maxCount,
|
|
120
|
+
selectFields
|
|
121
|
+
) {
|
|
122
|
+
// Track search strategies attempted
|
|
123
|
+
const searchAttempts = [];
|
|
124
|
+
|
|
125
|
+
// 0. If raw KQL query provided, use it directly
|
|
126
|
+
if (searchTerms.kqlQuery) {
|
|
127
|
+
try {
|
|
128
|
+
console.error(`Attempting raw KQL search: "${searchTerms.kqlQuery}"`);
|
|
129
|
+
searchAttempts.push('raw-kql');
|
|
130
|
+
|
|
131
|
+
const kqlParams = {
|
|
132
|
+
$top: Math.min(50, maxCount),
|
|
133
|
+
$select: selectFields,
|
|
134
|
+
$search: `"${searchTerms.kqlQuery}"`,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const response = await callGraphAPIPaginated(
|
|
138
|
+
accessToken,
|
|
139
|
+
'GET',
|
|
140
|
+
endpoint,
|
|
141
|
+
kqlParams,
|
|
142
|
+
maxCount
|
|
143
|
+
);
|
|
144
|
+
if (response.value && response.value.length > 0) {
|
|
145
|
+
console.error(
|
|
146
|
+
`Raw KQL search successful: found ${response.value.length} results`
|
|
147
|
+
);
|
|
148
|
+
return response;
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error(`Raw KQL search failed: ${error.message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 1. Try combined search (most specific)
|
|
156
|
+
try {
|
|
157
|
+
const params = buildSearchParams(
|
|
158
|
+
searchTerms,
|
|
159
|
+
filterTerms,
|
|
160
|
+
Math.min(50, maxCount),
|
|
161
|
+
selectFields
|
|
162
|
+
);
|
|
163
|
+
console.error('Attempting combined search with params:', params);
|
|
164
|
+
searchAttempts.push('combined-search');
|
|
165
|
+
|
|
166
|
+
const response = await callGraphAPIPaginated(
|
|
167
|
+
accessToken,
|
|
168
|
+
'GET',
|
|
169
|
+
endpoint,
|
|
170
|
+
params,
|
|
171
|
+
maxCount
|
|
172
|
+
);
|
|
173
|
+
if (response.value && response.value.length > 0) {
|
|
174
|
+
console.error(
|
|
175
|
+
`Combined search successful: found ${response.value.length} results`
|
|
176
|
+
);
|
|
177
|
+
return response;
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
console.error(`Combined search failed: ${error.message}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 2. Try each search term individually, starting with most specific
|
|
184
|
+
const searchPriority = ['from', 'to', 'subject', 'query'];
|
|
185
|
+
|
|
186
|
+
for (const term of searchPriority) {
|
|
187
|
+
if (searchTerms[term]) {
|
|
188
|
+
try {
|
|
189
|
+
console.error(
|
|
190
|
+
`Attempting search with only ${term}: "${searchTerms[term]}"`
|
|
191
|
+
);
|
|
192
|
+
searchAttempts.push(`single-term-${term}`);
|
|
193
|
+
|
|
194
|
+
const simplifiedParams = {
|
|
195
|
+
$top: Math.min(50, maxCount),
|
|
196
|
+
$select: selectFields,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Use $filter for from/to/subject (more reliable on personal accounts),
|
|
200
|
+
// $search for free-text query only.
|
|
201
|
+
// NOTE: $filter and $orderby cannot be used together on mailbox - Graph API limitation
|
|
202
|
+
if (term === 'from') {
|
|
203
|
+
if (searchTerms[term].includes('@')) {
|
|
204
|
+
simplifiedParams.$filter = `from/emailAddress/address eq '${searchTerms[term]}'`;
|
|
205
|
+
} else {
|
|
206
|
+
simplifiedParams.$filter = `contains(from/emailAddress/name, '${searchTerms[term]}')`;
|
|
207
|
+
}
|
|
208
|
+
} else if (term === 'to') {
|
|
209
|
+
if (searchTerms[term].includes('@')) {
|
|
210
|
+
simplifiedParams.$filter = `toRecipients/any(r: r/emailAddress/address eq '${searchTerms[term]}')`;
|
|
211
|
+
} else {
|
|
212
|
+
simplifiedParams.$filter = `toRecipients/any(r: contains(r/emailAddress/name, '${searchTerms[term]}'))`;
|
|
213
|
+
}
|
|
214
|
+
} else if (term === 'subject') {
|
|
215
|
+
// Use $filter with contains() — $search silently fails on personal MS accounts
|
|
216
|
+
simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
|
|
217
|
+
} else if (term === 'query') {
|
|
218
|
+
simplifiedParams.$orderby = 'receivedDateTime desc';
|
|
219
|
+
simplifiedParams.$search = `"${searchTerms[term]}"`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Add boolean filters if applicable
|
|
223
|
+
addBooleanFilters(simplifiedParams, filterTerms);
|
|
224
|
+
|
|
225
|
+
const response = await callGraphAPIPaginated(
|
|
226
|
+
accessToken,
|
|
227
|
+
'GET',
|
|
228
|
+
endpoint,
|
|
229
|
+
simplifiedParams,
|
|
230
|
+
maxCount
|
|
231
|
+
);
|
|
232
|
+
if (response.value && response.value.length > 0) {
|
|
233
|
+
console.error(
|
|
234
|
+
`Search with ${term} successful: found ${response.value.length} results`
|
|
235
|
+
);
|
|
236
|
+
return response;
|
|
237
|
+
}
|
|
238
|
+
} catch (error) {
|
|
239
|
+
console.error(`Search with ${term} failed: ${error.message}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 3. Try with only boolean filters
|
|
245
|
+
if (filterTerms.hasAttachments === true || filterTerms.unreadOnly === true) {
|
|
246
|
+
try {
|
|
247
|
+
console.error('Attempting search with only boolean filters');
|
|
248
|
+
searchAttempts.push('boolean-filters-only');
|
|
249
|
+
|
|
250
|
+
const filterOnlyParams = {
|
|
251
|
+
$top: Math.min(50, maxCount),
|
|
252
|
+
$select: selectFields,
|
|
253
|
+
$orderby: 'receivedDateTime desc',
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Add the boolean filters
|
|
257
|
+
addBooleanFilters(filterOnlyParams, filterTerms);
|
|
258
|
+
|
|
259
|
+
const response = await callGraphAPIPaginated(
|
|
260
|
+
accessToken,
|
|
261
|
+
'GET',
|
|
262
|
+
endpoint,
|
|
263
|
+
filterOnlyParams,
|
|
264
|
+
maxCount
|
|
265
|
+
);
|
|
266
|
+
console.error(
|
|
267
|
+
`Boolean filter search found ${response.value?.length || 0} results`
|
|
268
|
+
);
|
|
269
|
+
return response;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
console.error(`Boolean filter search failed: ${error.message}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 4. Final fallback: just get recent emails with pagination
|
|
276
|
+
console.error('All search strategies failed, falling back to recent emails');
|
|
277
|
+
searchAttempts.push('recent-emails');
|
|
278
|
+
|
|
279
|
+
const basicParams = {
|
|
280
|
+
$top: Math.min(50, maxCount),
|
|
281
|
+
$select: selectFields,
|
|
282
|
+
$orderby: 'receivedDateTime desc',
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const response = await callGraphAPIPaginated(
|
|
286
|
+
accessToken,
|
|
287
|
+
'GET',
|
|
288
|
+
endpoint,
|
|
289
|
+
basicParams,
|
|
290
|
+
maxCount
|
|
291
|
+
);
|
|
292
|
+
console.error(
|
|
293
|
+
`Fallback to recent emails found ${response.value?.length || 0} results`
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// Add a note to the response about the search attempts
|
|
297
|
+
response._searchInfo = {
|
|
298
|
+
attemptsCount: searchAttempts.length,
|
|
299
|
+
strategies: searchAttempts,
|
|
300
|
+
originalTerms: searchTerms,
|
|
301
|
+
filterTerms: filterTerms,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
return response;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Build search parameters from search terms and filter terms
|
|
309
|
+
* Uses $filter for email addresses (more reliable than $search)
|
|
310
|
+
* Uses $search only for general query and subject
|
|
311
|
+
* @param {object} searchTerms - Search terms (query, from, to, subject)
|
|
312
|
+
* @param {object} filterTerms - Filter terms (hasAttachments, unreadOnly)
|
|
313
|
+
* @param {number} count - Maximum number of results
|
|
314
|
+
* @param {string} selectFields - Comma-separated field list for $select
|
|
315
|
+
* @returns {object} - Query parameters
|
|
316
|
+
*/
|
|
317
|
+
function buildSearchParams(searchTerms, filterTerms, count, selectFields) {
|
|
318
|
+
const params = {
|
|
319
|
+
$top: count,
|
|
320
|
+
$select: selectFields,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Track if we're using email address filters (which are incompatible with $orderby)
|
|
324
|
+
let usesEmailFilter = false;
|
|
325
|
+
|
|
326
|
+
// Handle search terms - use $search only for free-text query
|
|
327
|
+
if (searchTerms.query) {
|
|
328
|
+
params.$search = `"${searchTerms.query}"`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Build filter conditions array - use $filter for structured fields (more reliable)
|
|
332
|
+
const filterConditions = [];
|
|
333
|
+
|
|
334
|
+
// Use $filter for subject — $search silently fails on personal MS accounts
|
|
335
|
+
if (searchTerms.subject) {
|
|
336
|
+
filterConditions.push(
|
|
337
|
+
`contains(subject, '${searchTerms.subject.replace(/'/g, "''")}')`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Use $filter for from/to email addresses (much more reliable than $search)
|
|
342
|
+
// NOTE: $filter on email addresses is incompatible with $orderby - Graph API limitation
|
|
343
|
+
if (searchTerms.from) {
|
|
344
|
+
usesEmailFilter = true;
|
|
345
|
+
if (searchTerms.from.includes('@')) {
|
|
346
|
+
filterConditions.push(
|
|
347
|
+
`from/emailAddress/address eq '${searchTerms.from}'`
|
|
348
|
+
);
|
|
349
|
+
} else {
|
|
350
|
+
filterConditions.push(
|
|
351
|
+
`contains(from/emailAddress/name, '${searchTerms.from}')`
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (searchTerms.to) {
|
|
357
|
+
usesEmailFilter = true;
|
|
358
|
+
if (searchTerms.to.includes('@')) {
|
|
359
|
+
filterConditions.push(
|
|
360
|
+
`toRecipients/any(r: r/emailAddress/address eq '${searchTerms.to}')`
|
|
361
|
+
);
|
|
362
|
+
} else {
|
|
363
|
+
filterConditions.push(
|
|
364
|
+
`toRecipients/any(r: contains(r/emailAddress/name, '${searchTerms.to}'))`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Add boolean filters (these ARE compatible with $orderby)
|
|
370
|
+
if (filterTerms.hasAttachments === true) {
|
|
371
|
+
filterConditions.push('hasAttachments eq true');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (filterTerms.unreadOnly === true) {
|
|
375
|
+
filterConditions.push('isRead eq false');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Add date range filters
|
|
379
|
+
if (filterTerms.receivedAfter) {
|
|
380
|
+
try {
|
|
381
|
+
const afterDate = new Date(filterTerms.receivedAfter).toISOString();
|
|
382
|
+
filterConditions.push(`receivedDateTime ge ${afterDate}`);
|
|
383
|
+
} catch (_e) {
|
|
384
|
+
console.error(`Invalid receivedAfter date: ${filterTerms.receivedAfter}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (filterTerms.receivedBefore) {
|
|
389
|
+
try {
|
|
390
|
+
const beforeDate = new Date(filterTerms.receivedBefore).toISOString();
|
|
391
|
+
filterConditions.push(`receivedDateTime le ${beforeDate}`);
|
|
392
|
+
} catch (_e) {
|
|
393
|
+
console.error(
|
|
394
|
+
`Invalid receivedBefore date: ${filterTerms.receivedBefore}`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Only add $orderby if we're NOT using email address filters
|
|
400
|
+
if (!usesEmailFilter) {
|
|
401
|
+
params.$orderby = 'receivedDateTime desc';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Combine all filter conditions
|
|
405
|
+
if (filterConditions.length > 0) {
|
|
406
|
+
params.$filter = filterConditions.join(' and ');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return params;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Add boolean and date filters to query parameters
|
|
414
|
+
* @param {object} params - Query parameters
|
|
415
|
+
* @param {object} filterTerms - Filter terms (hasAttachments, unreadOnly, receivedAfter, receivedBefore)
|
|
416
|
+
*/
|
|
417
|
+
function addBooleanFilters(params, filterTerms) {
|
|
418
|
+
const filterConditions = [];
|
|
419
|
+
|
|
420
|
+
if (filterTerms.hasAttachments === true) {
|
|
421
|
+
filterConditions.push('hasAttachments eq true');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (filterTerms.unreadOnly === true) {
|
|
425
|
+
filterConditions.push('isRead eq false');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Add date range filters
|
|
429
|
+
if (filterTerms.receivedAfter) {
|
|
430
|
+
try {
|
|
431
|
+
const afterDate = new Date(filterTerms.receivedAfter).toISOString();
|
|
432
|
+
filterConditions.push(`receivedDateTime ge ${afterDate}`);
|
|
433
|
+
} catch (_e) {
|
|
434
|
+
console.error(`Invalid receivedAfter date: ${filterTerms.receivedAfter}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (filterTerms.receivedBefore) {
|
|
439
|
+
try {
|
|
440
|
+
const beforeDate = new Date(filterTerms.receivedBefore).toISOString();
|
|
441
|
+
filterConditions.push(`receivedDateTime le ${beforeDate}`);
|
|
442
|
+
} catch (_e) {
|
|
443
|
+
console.error(
|
|
444
|
+
`Invalid receivedBefore date: ${filterTerms.receivedBefore}`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Add $filter parameter if we have any filter conditions
|
|
450
|
+
if (filterConditions.length > 0) {
|
|
451
|
+
params.$filter = filterConditions.join(' and ');
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Format search results into Markdown using response-formatter utilities
|
|
457
|
+
* @param {object} response - The API response object
|
|
458
|
+
* @param {string} folder - Folder that was searched
|
|
459
|
+
* @param {string} verbosity - Output verbosity level
|
|
460
|
+
* @returns {object} - MCP response object
|
|
461
|
+
*/
|
|
462
|
+
function formatSearchResults(response, folder, verbosity) {
|
|
463
|
+
if (!response.value || response.value.length === 0) {
|
|
464
|
+
return {
|
|
465
|
+
content: [
|
|
466
|
+
{
|
|
467
|
+
type: 'text',
|
|
468
|
+
text: `No emails found matching your search criteria.`,
|
|
469
|
+
},
|
|
470
|
+
],
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Build metadata
|
|
475
|
+
const meta = {
|
|
476
|
+
returned: response.value.length,
|
|
477
|
+
totalAvailable: response['@odata.count'] || null,
|
|
478
|
+
hasMore: !!response['@odata.nextLink'],
|
|
479
|
+
verbosity: verbosity,
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// Add search strategy info if available (for debugging)
|
|
483
|
+
let searchNote = '';
|
|
484
|
+
if (response._searchInfo) {
|
|
485
|
+
const strategy =
|
|
486
|
+
response._searchInfo.strategies[
|
|
487
|
+
response._searchInfo.strategies.length - 1
|
|
488
|
+
];
|
|
489
|
+
if (strategy === 'recent-emails') {
|
|
490
|
+
searchNote =
|
|
491
|
+
'\n\n**Note**: Your search query could not be applied — showing recent emails instead. ' +
|
|
492
|
+
'On personal Microsoft accounts, free-text `query` search may not work. ' +
|
|
493
|
+
'Try using `subject`, `from`, `to`, or `kqlQuery` parameters for more reliable filtering.';
|
|
494
|
+
} else {
|
|
495
|
+
searchNote = `\n\n_Search strategy: ${strategy}_`;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Format results using shared formatter
|
|
500
|
+
const formattedOutput = formatEmailList(
|
|
501
|
+
response.value,
|
|
502
|
+
`Search Results (${folder})`,
|
|
503
|
+
verbosity,
|
|
504
|
+
meta
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
content: [
|
|
509
|
+
{
|
|
510
|
+
type: 'text',
|
|
511
|
+
text: formattedOutput + searchNote,
|
|
512
|
+
},
|
|
513
|
+
],
|
|
514
|
+
_meta: meta,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Search for email by Message-ID header
|
|
520
|
+
* @param {object} args - Tool arguments
|
|
521
|
+
* @param {string} args.messageId - Full Message-ID header value (e.g., <abc123@example.com>)
|
|
522
|
+
* @param {string} [args.outputVerbosity] - Output detail level
|
|
523
|
+
* @returns {object} - MCP response with matching email(s)
|
|
524
|
+
*/
|
|
525
|
+
async function handleSearchByMessageId(args) {
|
|
526
|
+
const messageId = args.messageId;
|
|
527
|
+
const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
|
|
528
|
+
|
|
529
|
+
if (!messageId) {
|
|
530
|
+
return {
|
|
531
|
+
content: [
|
|
532
|
+
{
|
|
533
|
+
type: 'text',
|
|
534
|
+
text: 'Message-ID is required. Provide the full Message-ID header value (e.g., <abc123@example.com>)',
|
|
535
|
+
},
|
|
536
|
+
],
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
const accessToken = await ensureAuthenticated();
|
|
542
|
+
|
|
543
|
+
// Search across all folders for the Message-ID
|
|
544
|
+
const selectFields = getEmailFields(
|
|
545
|
+
verbosity === VERBOSITY.FULL ? 'forensic' : 'read'
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
// Build filter - need to escape the Message-ID properly
|
|
549
|
+
// Graph API expects: internetMessageId eq '<value>'
|
|
550
|
+
const escapedMessageId = messageId.replace(/'/g, "''");
|
|
551
|
+
|
|
552
|
+
const params = {
|
|
553
|
+
$filter: `internetMessageId eq '${escapedMessageId}'`,
|
|
554
|
+
$select: selectFields,
|
|
555
|
+
$top: '10', // Usually only one match, but allow for edge cases
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
console.error(`Searching for Message-ID: ${messageId}`);
|
|
559
|
+
|
|
560
|
+
const response = await callGraphAPI(
|
|
561
|
+
accessToken,
|
|
562
|
+
'GET',
|
|
563
|
+
'me/messages',
|
|
564
|
+
null,
|
|
565
|
+
params
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
const emails = response.value || [];
|
|
569
|
+
|
|
570
|
+
if (emails.length === 0) {
|
|
571
|
+
return {
|
|
572
|
+
content: [
|
|
573
|
+
{
|
|
574
|
+
type: 'text',
|
|
575
|
+
text: `## No Email Found\n\nNo email found with Message-ID: \`${messageId}\`\n\n**Tips:**\n- Ensure the full Message-ID is provided including angle brackets\n- Message-ID format: \`<unique-id@domain.com>\`\n- The email may have been deleted or not yet synced`,
|
|
576
|
+
},
|
|
577
|
+
],
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Format results
|
|
582
|
+
let resultText = `## Message-ID Search Results\n\n`;
|
|
583
|
+
resultText += `**Query:** \`${messageId}\`\n`;
|
|
584
|
+
resultText += `**Found:** ${emails.length} email(s)\n\n`;
|
|
585
|
+
|
|
586
|
+
// Use formatEmailList for consistent output
|
|
587
|
+
resultText += formatEmailList(emails, 'Match', verbosity);
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
content: [
|
|
591
|
+
{
|
|
592
|
+
type: 'text',
|
|
593
|
+
text: resultText,
|
|
594
|
+
},
|
|
595
|
+
],
|
|
596
|
+
_meta: {
|
|
597
|
+
messageId: messageId,
|
|
598
|
+
matchCount: emails.length,
|
|
599
|
+
emailIds: emails.map((e) => e.id),
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
} catch (error) {
|
|
603
|
+
if (error.message === 'Authentication required') {
|
|
604
|
+
return {
|
|
605
|
+
content: [
|
|
606
|
+
{
|
|
607
|
+
type: 'text',
|
|
608
|
+
text: "Authentication required. Please use the 'authenticate' tool first.",
|
|
609
|
+
},
|
|
610
|
+
],
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
content: [
|
|
616
|
+
{
|
|
617
|
+
type: 'text',
|
|
618
|
+
text: `Error searching by Message-ID: ${error.message}`,
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
module.exports = {
|
|
626
|
+
handleSearchEmails,
|
|
627
|
+
handleSearchByMessageId,
|
|
628
|
+
};
|