@littlebearapps/outlook-assistant 3.5.1 → 3.5.2
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/README.md +5 -4
- package/auth/index.js +2 -1
- package/auth/tools.js +8 -1
- package/categories/index.js +26 -5
- package/email/conversations.js +59 -1
- package/email/mail-tips.js +36 -3
- package/email/search.js +132 -59
- package/email/send.js +32 -43
- package/index.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -149,10 +149,11 @@ npx @littlebearapps/outlook-assistant
|
|
|
149
149
|
You need a Microsoft Azure app registration to authenticate. See the **[Azure Setup Guide](docs/guides/azure-setup.md)** for a detailed walkthrough (including first-time Azure account creation), or if you've done this before:
|
|
150
150
|
|
|
151
151
|
1. Create a new app registration at [portal.azure.com](https://portal.azure.com/)
|
|
152
|
-
2.
|
|
153
|
-
3.
|
|
154
|
-
4.
|
|
155
|
-
5. Enable **"Allow public client flows"** in Authentication > Advanced settings
|
|
152
|
+
2. Add Microsoft Graph delegated permissions (Mail, Calendar, Contacts)
|
|
153
|
+
3. Create a client secret and copy the **Value** (not the Secret ID)
|
|
154
|
+
4. Under Authentication > **Add a platform** > **Mobile and desktop applications** — check `nativeclient` URI
|
|
155
|
+
5. Enable **"Allow public client flows"** in Authentication > Advanced settings
|
|
156
|
+
6. _(Optional)_ Set redirect URI to `http://localhost:3333/auth/callback` — only needed for browser auth flow
|
|
156
157
|
|
|
157
158
|
### 3. Configure Your MCP Client
|
|
158
159
|
|
package/auth/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
const tokenManager = require('./token-manager');
|
|
5
5
|
const TokenStorage = require('./token-storage');
|
|
6
6
|
const config = require('../config');
|
|
7
|
-
const { authTools } = require('./tools');
|
|
7
|
+
const { authTools, setToolCount } = require('./tools');
|
|
8
8
|
|
|
9
9
|
// Singleton TokenStorage instance with auto-refresh support
|
|
10
10
|
const tokenStorage = new TokenStorage({
|
|
@@ -39,5 +39,6 @@ module.exports = {
|
|
|
39
39
|
tokenManager, // deprecated: use tokenStorage
|
|
40
40
|
tokenStorage,
|
|
41
41
|
authTools,
|
|
42
|
+
setToolCount,
|
|
42
43
|
ensureAuthenticated,
|
|
43
44
|
};
|
package/auth/tools.js
CHANGED
|
@@ -5,6 +5,12 @@ const config = require('../config');
|
|
|
5
5
|
const tokenManager = require('./token-manager');
|
|
6
6
|
const { initiateDeviceCodeFlow, pollForToken } = require('./device-code');
|
|
7
7
|
|
|
8
|
+
// Dynamic tool count — set by index.js after TOOLS array is built
|
|
9
|
+
let _toolCount = 0;
|
|
10
|
+
function setToolCount(count) {
|
|
11
|
+
_toolCount = count;
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
/**
|
|
9
15
|
* About tool handler
|
|
10
16
|
* @returns {object} - MCP response
|
|
@@ -25,7 +31,7 @@ async function handleAbout() {
|
|
|
25
31
|
`## Diagnostics\n`,
|
|
26
32
|
`| Setting | Value |`,
|
|
27
33
|
`|---------|-------|`,
|
|
28
|
-
`| Tools |
|
|
34
|
+
`| Tools | ${_toolCount} across 9 modules |`,
|
|
29
35
|
`| Modules | auth, email, calendar, folder, rules, contacts, categories, settings, advanced |`,
|
|
30
36
|
`| Timezone | ${config.DEFAULT_TIMEZONE} |`,
|
|
31
37
|
`| Test Mode | ${testMode} |`,
|
|
@@ -320,6 +326,7 @@ const authTools = [
|
|
|
320
326
|
|
|
321
327
|
module.exports = {
|
|
322
328
|
authTools,
|
|
329
|
+
setToolCount,
|
|
323
330
|
handleAbout,
|
|
324
331
|
handleAuthenticate,
|
|
325
332
|
handleDeviceCodeAuth,
|
package/categories/index.js
CHANGED
|
@@ -305,17 +305,26 @@ async function handleUpdateCategory(args) {
|
|
|
305
305
|
updateData
|
|
306
306
|
);
|
|
307
307
|
|
|
308
|
-
|
|
308
|
+
// Prefer input values over response (PATCH may return partial data)
|
|
309
|
+
const updatedName = displayName || response.displayName;
|
|
310
|
+
const updatedColor = color || response.color;
|
|
311
|
+
const colorName = COLOR_NAMES[updatedColor] || updatedColor;
|
|
312
|
+
|
|
313
|
+
const updatedCategory = {
|
|
314
|
+
...response,
|
|
315
|
+
displayName: updatedName,
|
|
316
|
+
color: updatedColor,
|
|
317
|
+
};
|
|
309
318
|
|
|
310
319
|
return {
|
|
311
320
|
content: [
|
|
312
321
|
{
|
|
313
322
|
type: 'text',
|
|
314
|
-
text: `Category updated!\n\n**Name**: ${
|
|
323
|
+
text: `Category updated!\n\n**Name**: ${updatedName}\n**Color**: ${colorName} (${updatedColor})\n**ID**: ${response.id || id}`,
|
|
315
324
|
},
|
|
316
325
|
],
|
|
317
326
|
_meta: {
|
|
318
|
-
category: formatCategory(
|
|
327
|
+
category: formatCategory(updatedCategory),
|
|
319
328
|
},
|
|
320
329
|
};
|
|
321
330
|
} catch (error) {
|
|
@@ -428,7 +437,9 @@ async function handleApplyCategory(args) {
|
|
|
428
437
|
};
|
|
429
438
|
}
|
|
430
439
|
|
|
431
|
-
|
|
440
|
+
const applyAction = action || 'set'; // 'set', 'add', 'remove'
|
|
441
|
+
|
|
442
|
+
if (!categories || !Array.isArray(categories)) {
|
|
432
443
|
return {
|
|
433
444
|
content: [
|
|
434
445
|
{
|
|
@@ -439,7 +450,17 @@ async function handleApplyCategory(args) {
|
|
|
439
450
|
};
|
|
440
451
|
}
|
|
441
452
|
|
|
442
|
-
|
|
453
|
+
// Empty array is only valid for action=set (clears all categories)
|
|
454
|
+
if (categories.length === 0 && applyAction !== 'set') {
|
|
455
|
+
return {
|
|
456
|
+
content: [
|
|
457
|
+
{
|
|
458
|
+
type: 'text',
|
|
459
|
+
text: 'Categories array cannot be empty for add/remove. Use action=set with an empty array to clear all categories.',
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
};
|
|
463
|
+
}
|
|
443
464
|
|
|
444
465
|
try {
|
|
445
466
|
const accessToken = await ensureAuthenticated();
|
package/email/conversations.js
CHANGED
|
@@ -17,6 +17,8 @@ const {
|
|
|
17
17
|
formatEmailsAsCSV,
|
|
18
18
|
VERBOSITY,
|
|
19
19
|
} = require('../utils/response-formatter');
|
|
20
|
+
// Note: buildFromFilter/buildToFilter from search.js use OData $filter which causes
|
|
21
|
+
// InefficientFilter on personal accounts with $orderby. Client-side filtering used instead.
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Format a date for filenames
|
|
@@ -65,10 +67,12 @@ async function handleListConversations(args) {
|
|
|
65
67
|
'id',
|
|
66
68
|
'subject',
|
|
67
69
|
'from',
|
|
70
|
+
'toRecipients',
|
|
68
71
|
'receivedDateTime',
|
|
69
72
|
'conversationId',
|
|
70
73
|
'conversationIndex',
|
|
71
74
|
'isRead',
|
|
75
|
+
'hasAttachments',
|
|
72
76
|
'bodyPreview',
|
|
73
77
|
].join(',');
|
|
74
78
|
|
|
@@ -79,6 +83,34 @@ async function handleListConversations(args) {
|
|
|
79
83
|
$top: 200, // Get more to group
|
|
80
84
|
};
|
|
81
85
|
|
|
86
|
+
// Apply simple $filter conditions that Graph API supports on personal accounts
|
|
87
|
+
// Complex filters (contains on subject, endswith on email) cause InefficientFilter
|
|
88
|
+
// errors, so those are handled client-side after fetching.
|
|
89
|
+
const serverFilterConditions = [];
|
|
90
|
+
if (args.hasAttachments === true) {
|
|
91
|
+
serverFilterConditions.push('hasAttachments eq true');
|
|
92
|
+
}
|
|
93
|
+
if (args.receivedAfter) {
|
|
94
|
+
try {
|
|
95
|
+
const afterDate = new Date(args.receivedAfter).toISOString();
|
|
96
|
+
serverFilterConditions.push(`receivedDateTime ge ${afterDate}`);
|
|
97
|
+
} catch (_e) {
|
|
98
|
+
/* ignore invalid date */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (args.receivedBefore) {
|
|
102
|
+
try {
|
|
103
|
+
const beforeDate = new Date(args.receivedBefore).toISOString();
|
|
104
|
+
serverFilterConditions.push(`receivedDateTime le ${beforeDate}`);
|
|
105
|
+
} catch (_e) {
|
|
106
|
+
/* ignore invalid date */
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (serverFilterConditions.length > 0) {
|
|
111
|
+
queryParams.$filter = serverFilterConditions.join(' and ');
|
|
112
|
+
}
|
|
113
|
+
|
|
82
114
|
const response = await callGraphAPI(
|
|
83
115
|
accessToken,
|
|
84
116
|
'GET',
|
|
@@ -86,7 +118,33 @@ async function handleListConversations(args) {
|
|
|
86
118
|
null,
|
|
87
119
|
queryParams
|
|
88
120
|
);
|
|
89
|
-
|
|
121
|
+
let messages = response.value || [];
|
|
122
|
+
|
|
123
|
+
// Client-side filtering for conditions that cause InefficientFilter on personal accounts
|
|
124
|
+
if (args.subject) {
|
|
125
|
+
const subjectLower = args.subject.toLowerCase();
|
|
126
|
+
messages = messages.filter((m) =>
|
|
127
|
+
(m.subject || '').toLowerCase().includes(subjectLower)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (args.from) {
|
|
131
|
+
const fromLower = args.from.toLowerCase();
|
|
132
|
+
messages = messages.filter((m) => {
|
|
133
|
+
const addr = (m.from?.emailAddress?.address || '').toLowerCase();
|
|
134
|
+
const name = (m.from?.emailAddress?.name || '').toLowerCase();
|
|
135
|
+
return addr.includes(fromLower) || name.includes(fromLower);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (args.to) {
|
|
139
|
+
const toLower = args.to.toLowerCase();
|
|
140
|
+
messages = messages.filter((m) =>
|
|
141
|
+
(m.toRecipients || []).some((r) => {
|
|
142
|
+
const addr = (r.emailAddress?.address || '').toLowerCase();
|
|
143
|
+
const name = (r.emailAddress?.name || '').toLowerCase();
|
|
144
|
+
return addr.includes(toLower) || name.includes(toLower);
|
|
145
|
+
})
|
|
146
|
+
);
|
|
147
|
+
}
|
|
90
148
|
|
|
91
149
|
// Group by conversationId
|
|
92
150
|
const conversations = new Map();
|
package/email/mail-tips.js
CHANGED
|
@@ -146,13 +146,46 @@ async function handleGetMailTips(args) {
|
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// Normalise recipients to a clean array of email strings
|
|
150
|
+
let recipientList;
|
|
151
|
+
if (Array.isArray(recipients)) {
|
|
152
|
+
recipientList = recipients.map((e) => String(e).trim());
|
|
153
|
+
} else if (typeof recipients === 'string') {
|
|
154
|
+
// Handle JSON array strings like '["a@b.com","c@d.com"]' from MCP clients
|
|
155
|
+
const trimmed = recipients.trim();
|
|
156
|
+
if (trimmed.startsWith('[')) {
|
|
157
|
+
try {
|
|
158
|
+
recipientList = JSON.parse(trimmed).map((e) => String(e).trim());
|
|
159
|
+
} catch (_e) {
|
|
160
|
+
// Fall through to comma-split
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!recipientList) {
|
|
164
|
+
recipientList = trimmed.split(',').map((e) => e.trim());
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
recipientList = [String(recipients).trim()];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Filter out empty strings
|
|
171
|
+
recipientList = recipientList.filter((e) => e.length > 0);
|
|
172
|
+
|
|
173
|
+
if (recipientList.length === 0) {
|
|
174
|
+
return {
|
|
175
|
+
content: [
|
|
176
|
+
{
|
|
177
|
+
type: 'text',
|
|
178
|
+
text: 'At least one valid recipient email address is required.',
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
149
184
|
try {
|
|
150
185
|
const accessToken = await ensureAuthenticated();
|
|
151
186
|
|
|
152
187
|
const requestBody = {
|
|
153
|
-
EmailAddresses:
|
|
154
|
-
? recipients
|
|
155
|
-
: recipients.split(',').map((e) => e.trim()),
|
|
188
|
+
EmailAddresses: recipientList,
|
|
156
189
|
MailTipsOptions: tipTypes || MAIL_TIP_TYPES.join(','),
|
|
157
190
|
};
|
|
158
191
|
|
package/email/search.js
CHANGED
|
@@ -152,33 +152,47 @@ async function progressiveSearch(
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
155
|
+
// Check if we have any actual search terms (not just boolean filters)
|
|
156
|
+
const hasSearchTerms =
|
|
157
|
+
searchTerms.query ||
|
|
158
|
+
searchTerms.from ||
|
|
159
|
+
searchTerms.to ||
|
|
160
|
+
searchTerms.subject;
|
|
161
|
+
|
|
162
|
+
// 1. Try combined search (most specific) — skip if only boolean filters
|
|
163
|
+
if (
|
|
164
|
+
!hasSearchTerms &&
|
|
165
|
+
(filterTerms.hasAttachments === true || filterTerms.unreadOnly === true)
|
|
166
|
+
) {
|
|
167
|
+
// Skip directly to boolean-only filter (step 3) — combined search is redundant
|
|
168
|
+
console.error('Only boolean filters provided, skipping combined search');
|
|
169
|
+
} else
|
|
170
|
+
try {
|
|
171
|
+
const params = buildSearchParams(
|
|
172
|
+
searchTerms,
|
|
173
|
+
filterTerms,
|
|
174
|
+
Math.min(50, maxCount),
|
|
175
|
+
selectFields
|
|
176
|
+
);
|
|
177
|
+
console.error('Attempting combined search with params:', params);
|
|
178
|
+
searchAttempts.push('combined-search');
|
|
165
179
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
);
|
|
173
|
-
if (response.value && response.value.length > 0) {
|
|
174
|
-
console.error(
|
|
175
|
-
`Combined search successful: found ${response.value.length} results`
|
|
180
|
+
const response = await callGraphAPIPaginated(
|
|
181
|
+
accessToken,
|
|
182
|
+
'GET',
|
|
183
|
+
endpoint,
|
|
184
|
+
params,
|
|
185
|
+
maxCount
|
|
176
186
|
);
|
|
177
|
-
|
|
187
|
+
if (response.value && response.value.length > 0) {
|
|
188
|
+
console.error(
|
|
189
|
+
`Combined search successful: found ${response.value.length} results`
|
|
190
|
+
);
|
|
191
|
+
return response;
|
|
192
|
+
}
|
|
193
|
+
} catch (error) {
|
|
194
|
+
console.error(`Combined search failed: ${error.message}`);
|
|
178
195
|
}
|
|
179
|
-
} catch (error) {
|
|
180
|
-
console.error(`Combined search failed: ${error.message}`);
|
|
181
|
-
}
|
|
182
196
|
|
|
183
197
|
// 2. Try each search term individually, starting with most specific
|
|
184
198
|
const searchPriority = ['from', 'to', 'subject', 'query'];
|
|
@@ -200,23 +214,16 @@ async function progressiveSearch(
|
|
|
200
214
|
// $search for free-text query only.
|
|
201
215
|
// NOTE: $filter and $orderby cannot be used together on mailbox - Graph API limitation
|
|
202
216
|
if (term === 'from') {
|
|
203
|
-
|
|
204
|
-
simplifiedParams.$filter = `from/emailAddress/address eq '${searchTerms[term]}'`;
|
|
205
|
-
} else {
|
|
206
|
-
simplifiedParams.$filter = `contains(from/emailAddress/name, '${searchTerms[term]}')`;
|
|
207
|
-
}
|
|
217
|
+
simplifiedParams.$filter = buildFromFilter(searchTerms[term]);
|
|
208
218
|
} else if (term === 'to') {
|
|
209
|
-
|
|
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
|
-
}
|
|
219
|
+
simplifiedParams.$filter = buildToFilter(searchTerms[term]);
|
|
214
220
|
} else if (term === 'subject') {
|
|
215
221
|
// Use $filter with contains() — $search silently fails on personal MS accounts
|
|
216
222
|
simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
|
|
217
223
|
} else if (term === 'query') {
|
|
218
|
-
|
|
219
|
-
|
|
224
|
+
// On personal accounts, $search fails with 503. Use $filter with
|
|
225
|
+
// contains(subject) as a best-effort fallback for free-text queries.
|
|
226
|
+
simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
|
|
220
227
|
}
|
|
221
228
|
|
|
222
229
|
// Add boolean filters if applicable
|
|
@@ -241,21 +248,29 @@ async function progressiveSearch(
|
|
|
241
248
|
}
|
|
242
249
|
}
|
|
243
250
|
|
|
244
|
-
// 3. Try with only boolean filters
|
|
245
|
-
|
|
251
|
+
// 3. Try with only boolean filters (also date range filters)
|
|
252
|
+
const hasBooleanFilters =
|
|
253
|
+
filterTerms.hasAttachments === true || filterTerms.unreadOnly === true;
|
|
254
|
+
const hasDateFilters =
|
|
255
|
+
filterTerms.receivedAfter || filterTerms.receivedBefore;
|
|
256
|
+
if (hasBooleanFilters || hasDateFilters) {
|
|
246
257
|
try {
|
|
247
|
-
console.error('Attempting search with only boolean filters');
|
|
258
|
+
console.error('Attempting search with only boolean/date filters');
|
|
248
259
|
searchAttempts.push('boolean-filters-only');
|
|
249
260
|
|
|
250
261
|
const filterOnlyParams = {
|
|
251
262
|
$top: Math.min(50, maxCount),
|
|
252
263
|
$select: selectFields,
|
|
253
|
-
$orderby: 'receivedDateTime desc',
|
|
254
264
|
};
|
|
255
265
|
|
|
256
|
-
// Add the boolean filters
|
|
266
|
+
// Add the boolean + date filters
|
|
257
267
|
addBooleanFilters(filterOnlyParams, filterTerms);
|
|
258
268
|
|
|
269
|
+
// Only add $orderby if no $filter (they can conflict on personal accounts)
|
|
270
|
+
if (!filterOnlyParams.$filter) {
|
|
271
|
+
filterOnlyParams.$orderby = 'receivedDateTime desc';
|
|
272
|
+
}
|
|
273
|
+
|
|
259
274
|
const response = await callGraphAPIPaginated(
|
|
260
275
|
accessToken,
|
|
261
276
|
'GET',
|
|
@@ -269,6 +284,29 @@ async function progressiveSearch(
|
|
|
269
284
|
return response;
|
|
270
285
|
} catch (error) {
|
|
271
286
|
console.error(`Boolean filter search failed: ${error.message}`);
|
|
287
|
+
// Retry without $orderby if it was the issue
|
|
288
|
+
if (error.message && error.message.includes('InefficientFilter')) {
|
|
289
|
+
try {
|
|
290
|
+
console.error('Retrying boolean filters without $orderby');
|
|
291
|
+
const retryParams = {
|
|
292
|
+
$top: Math.min(50, maxCount),
|
|
293
|
+
$select: selectFields,
|
|
294
|
+
};
|
|
295
|
+
addBooleanFilters(retryParams, filterTerms);
|
|
296
|
+
const response = await callGraphAPIPaginated(
|
|
297
|
+
accessToken,
|
|
298
|
+
'GET',
|
|
299
|
+
endpoint,
|
|
300
|
+
retryParams,
|
|
301
|
+
maxCount
|
|
302
|
+
);
|
|
303
|
+
return response;
|
|
304
|
+
} catch (retryError) {
|
|
305
|
+
console.error(
|
|
306
|
+
`Boolean filter retry also failed: ${retryError.message}`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
272
310
|
}
|
|
273
311
|
}
|
|
274
312
|
|
|
@@ -304,6 +342,54 @@ async function progressiveSearch(
|
|
|
304
342
|
return response;
|
|
305
343
|
}
|
|
306
344
|
|
|
345
|
+
/**
|
|
346
|
+
* Detect if a value is a domain-only filter (e.g. "@souliv.com.au" or "souliv.com.au")
|
|
347
|
+
* vs a full email address (e.g. "user@souliv.com.au") vs a display name (e.g. "Billie")
|
|
348
|
+
* @param {string} val - The from/to filter value
|
|
349
|
+
* @returns {'domain'|'email'|'name'} - The type of filter
|
|
350
|
+
*/
|
|
351
|
+
function classifyEmailFilter(val) {
|
|
352
|
+
if (val.startsWith('@')) return 'domain';
|
|
353
|
+
// Has dots but no @ — likely a domain like "souliv.com.au"
|
|
354
|
+
if (!val.includes('@') && val.includes('.')) return 'domain';
|
|
355
|
+
if (val.includes('@')) return 'email';
|
|
356
|
+
return 'name';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Build a $filter condition for a from field value
|
|
361
|
+
* @param {string} val - The from filter value
|
|
362
|
+
* @returns {string} - OData $filter condition
|
|
363
|
+
*/
|
|
364
|
+
function buildFromFilter(val) {
|
|
365
|
+
const type = classifyEmailFilter(val);
|
|
366
|
+
if (type === 'domain') {
|
|
367
|
+
// Use contains() — endswith() not supported on personal accounts
|
|
368
|
+
const domain = val.startsWith('@') ? val : `@${val}`;
|
|
369
|
+
return `contains(from/emailAddress/address, '${domain.substring(1)}')`;
|
|
370
|
+
} else if (type === 'email') {
|
|
371
|
+
return `from/emailAddress/address eq '${val}'`;
|
|
372
|
+
}
|
|
373
|
+
return `contains(from/emailAddress/name, '${val}')`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Build a $filter condition for a to field value
|
|
378
|
+
* @param {string} val - The to filter value
|
|
379
|
+
* @returns {string} - OData $filter condition
|
|
380
|
+
*/
|
|
381
|
+
function buildToFilter(val) {
|
|
382
|
+
const type = classifyEmailFilter(val);
|
|
383
|
+
if (type === 'domain') {
|
|
384
|
+
const domain = val.startsWith('@') ? val : `@${val}`;
|
|
385
|
+
// Use contains() — endswith() not supported on personal accounts
|
|
386
|
+
return `toRecipients/any(r: contains(r/emailAddress/address, '${domain.substring(1)}'))`;
|
|
387
|
+
} else if (type === 'email') {
|
|
388
|
+
return `toRecipients/any(r: r/emailAddress/address eq '${val}')`;
|
|
389
|
+
}
|
|
390
|
+
return `toRecipients/any(r: contains(r/emailAddress/name, '${val}'))`;
|
|
391
|
+
}
|
|
392
|
+
|
|
307
393
|
/**
|
|
308
394
|
* Build search parameters from search terms and filter terms
|
|
309
395
|
* Uses $filter for email addresses (more reliable than $search)
|
|
@@ -342,28 +428,12 @@ function buildSearchParams(searchTerms, filterTerms, count, selectFields) {
|
|
|
342
428
|
// NOTE: $filter on email addresses is incompatible with $orderby - Graph API limitation
|
|
343
429
|
if (searchTerms.from) {
|
|
344
430
|
usesEmailFilter = true;
|
|
345
|
-
|
|
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
|
-
}
|
|
431
|
+
filterConditions.push(buildFromFilter(searchTerms.from));
|
|
354
432
|
}
|
|
355
433
|
|
|
356
434
|
if (searchTerms.to) {
|
|
357
435
|
usesEmailFilter = true;
|
|
358
|
-
|
|
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
|
-
}
|
|
436
|
+
filterConditions.push(buildToFilter(searchTerms.to));
|
|
367
437
|
}
|
|
368
438
|
|
|
369
439
|
// Add boolean filters (these ARE compatible with $orderby)
|
|
@@ -625,4 +695,7 @@ async function handleSearchByMessageId(args) {
|
|
|
625
695
|
module.exports = {
|
|
626
696
|
handleSearchEmails,
|
|
627
697
|
handleSearchByMessageId,
|
|
698
|
+
buildFromFilter,
|
|
699
|
+
buildToFilter,
|
|
700
|
+
classifyEmailFilter,
|
|
628
701
|
};
|
package/email/send.js
CHANGED
|
@@ -101,49 +101,7 @@ async function handleSendEmail(args) {
|
|
|
101
101
|
const allowlistError = checkRecipientAllowlist(allRecipients);
|
|
102
102
|
if (allowlistError) return allowlistError;
|
|
103
103
|
|
|
104
|
-
//
|
|
105
|
-
if (checkRecipients) {
|
|
106
|
-
const allAddresses = allRecipients.map((r) => r.emailAddress.address);
|
|
107
|
-
const tipsResult = await handleGetMailTips({
|
|
108
|
-
recipients: allAddresses,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
// If there are warnings, prepend them to the response
|
|
112
|
-
if (tipsResult._meta?.warningCount > 0 && dryRun) {
|
|
113
|
-
// In dry-run mode, include mail tips in the preview
|
|
114
|
-
const tipsText = tipsResult.content[0]?.text || '';
|
|
115
|
-
const preview = formatDryRunPreview({
|
|
116
|
-
message: {
|
|
117
|
-
subject,
|
|
118
|
-
body: {
|
|
119
|
-
contentType:
|
|
120
|
-
/<(html|div|p|h[1-6]|br|table|ul|ol|li|span|a\s|img|strong|em|b|i)\b/i.test(
|
|
121
|
-
body
|
|
122
|
-
)
|
|
123
|
-
? 'html'
|
|
124
|
-
: 'text',
|
|
125
|
-
content: body,
|
|
126
|
-
},
|
|
127
|
-
toRecipients,
|
|
128
|
-
ccRecipients: ccRecipients.length > 0 ? ccRecipients : undefined,
|
|
129
|
-
bccRecipients: bccRecipients.length > 0 ? bccRecipients : undefined,
|
|
130
|
-
importance,
|
|
131
|
-
},
|
|
132
|
-
saveToSentItems,
|
|
133
|
-
});
|
|
134
|
-
return {
|
|
135
|
-
content: [
|
|
136
|
-
{
|
|
137
|
-
type: 'text',
|
|
138
|
-
text: tipsText + '\n\n---\n\n' + preview.content[0].text,
|
|
139
|
-
},
|
|
140
|
-
],
|
|
141
|
-
_meta: { mailTips: tipsResult._meta },
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Prepare email object
|
|
104
|
+
// Prepare email object (needed by both dryRun and actual send)
|
|
147
105
|
const emailObject = {
|
|
148
106
|
message: {
|
|
149
107
|
subject,
|
|
@@ -164,6 +122,37 @@ async function handleSendEmail(args) {
|
|
|
164
122
|
saveToSentItems,
|
|
165
123
|
};
|
|
166
124
|
|
|
125
|
+
// Pre-send mail tips check
|
|
126
|
+
if (checkRecipients) {
|
|
127
|
+
const allAddresses = allRecipients.map((r) => r.emailAddress.address);
|
|
128
|
+
const tipsResult = await handleGetMailTips({
|
|
129
|
+
recipients: allAddresses,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const tipsText = tipsResult.content[0]?.text || '';
|
|
133
|
+
|
|
134
|
+
// In dry-run mode, always include mail tips in the preview
|
|
135
|
+
if (dryRun) {
|
|
136
|
+
const preview = formatDryRunPreview(emailObject);
|
|
137
|
+
return {
|
|
138
|
+
content: [
|
|
139
|
+
{
|
|
140
|
+
type: 'text',
|
|
141
|
+
text: tipsText + '\n\n---\n\n' + preview.content[0].text,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
_meta: { mailTips: tipsResult._meta },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// In send mode, warn if there are issues but proceed
|
|
149
|
+
if (tipsResult._meta?.warningCount > 0) {
|
|
150
|
+
// Store tips to prepend to send response
|
|
151
|
+
emailObject._mailTipsText = tipsText;
|
|
152
|
+
emailObject._mailTipsMeta = tipsResult._meta;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
167
156
|
// Dry-run mode: return preview without sending
|
|
168
157
|
if (dryRun) {
|
|
169
158
|
return formatDryRunPreview(emailObject);
|
package/index.js
CHANGED
|
@@ -12,7 +12,7 @@ const {
|
|
|
12
12
|
const config = require('./config');
|
|
13
13
|
|
|
14
14
|
// Import module tools
|
|
15
|
-
const { authTools } = require('./auth');
|
|
15
|
+
const { authTools, setToolCount } = require('./auth');
|
|
16
16
|
const { calendarTools } = require('./calendar');
|
|
17
17
|
const { emailTools } = require('./email');
|
|
18
18
|
const { folderTools } = require('./folder');
|
|
@@ -39,6 +39,9 @@ const TOOLS = [
|
|
|
39
39
|
...advancedTools,
|
|
40
40
|
];
|
|
41
41
|
|
|
42
|
+
// Set dynamic tool count for auth about handler
|
|
43
|
+
setToolCount(TOOLS.length);
|
|
44
|
+
|
|
42
45
|
// Create server with tools capabilities
|
|
43
46
|
const server = new Server(
|
|
44
47
|
{ name: config.SERVER_NAME, version: config.SERVER_VERSION },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@littlebearapps/outlook-assistant",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.2",
|
|
4
4
|
"mcpName": "io.github.littlebearapps/outlook-assistant",
|
|
5
5
|
"description": "Outlook Assistant — MCP server with 21 tools for email, calendar, contacts, and settings via Microsoft Graph API",
|
|
6
6
|
"main": "index.js",
|