@littlebearapps/outlook-assistant 3.9.1 → 3.11.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/README.md +31 -5
- package/auth/auth-errors.js +89 -0
- package/auth/oauth-server.js +4 -3
- package/auth/token-storage.js +19 -8
- package/auth/tools.js +4 -13
- package/email/index.js +1 -1
- package/email/search.js +601 -69
- package/index.js +59 -0
- package/llms-install.md +1 -1
- package/llms.txt +3 -3
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -100,7 +100,7 @@ Outlook Assistant works with both personal and work/school Microsoft accounts, b
|
|
|
100
100
|
| Contacts CRUD | Full support | Full support |
|
|
101
101
|
| Inbox rules | Full support | Full support |
|
|
102
102
|
| Folders | Full support | Full support |
|
|
103
|
-
| Free-text `query` search | Limited —
|
|
103
|
+
| Free-text `query` search | Limited — progressive fallback; `subject`, `from`, `to` filters are more direct | Full `$search` support |
|
|
104
104
|
| Categories | Full support | Full support |
|
|
105
105
|
| Mailbox settings | Full support | Full support |
|
|
106
106
|
| Focused Inbox | API works (overrides stored) but mail routing not affected | Full support |
|
|
@@ -111,7 +111,7 @@ Outlook Assistant works with both personal and work/school Microsoft accounts, b
|
|
|
111
111
|
|
|
112
112
|
### What Makes This Different
|
|
113
113
|
|
|
114
|
-
- **Progressive search** — on accounts where Microsoft's `$search` API is limited, Outlook Assistant automatically falls back through up to 4 search strategies to find your emails. Most Graph API wrappers fail silently; this one adapts.
|
|
114
|
+
- **Progressive search** — on accounts where Microsoft's `$search` API is limited, Outlook Assistant automatically falls back through up to 4 search strategies to find your emails, and reports which one answered in `_meta.searchMetadata` along with any filter it could not honour (`droppedFilters`). Most Graph API wrappers fail silently; this one adapts and tells you.
|
|
115
115
|
- **Email forensics** — raw header access for DKIM, SPF, DMARC, delivery chain, X-Mailer, X-Originating-IP, and spam scores. Returns the full data so you can investigate phishing, audit compliance, or trace delivery issues. (Auto-verdict is on the roadmap; today the data is surfaced and analysed in-conversation.)
|
|
116
116
|
- **Delta sync** — incremental inbox monitoring returns only what changed since your last check, with tokens for continuous polling. Designed for agent workflows that need to watch a mailbox.
|
|
117
117
|
- **Batch operations** — flag, move, export, or categorise multiple emails in a single call. Search-driven export lets you batch-export results without collecting IDs manually.
|
|
@@ -161,6 +161,17 @@ Or run directly without installing:
|
|
|
161
161
|
npx @littlebearapps/outlook-assistant
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
+
To check which version you have, or to see the available options:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
outlook-assistant --version # prints e.g. 3.11.0
|
|
168
|
+
outlook-assistant --help # usage, options and key environment variables
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
With no arguments the server speaks the Model Context Protocol over stdio. It's
|
|
172
|
+
normally launched by your MCP client rather than run by hand — started from a
|
|
173
|
+
terminal it will simply wait on stdin.
|
|
174
|
+
|
|
164
175
|
### 2. Register an Azure App
|
|
165
176
|
|
|
166
177
|
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:
|
|
@@ -278,6 +289,17 @@ cd outlook-assistant
|
|
|
278
289
|
npm install
|
|
279
290
|
```
|
|
280
291
|
|
|
292
|
+
### CLI options
|
|
293
|
+
|
|
294
|
+
| Option | What it does |
|
|
295
|
+
|--------|-------------|
|
|
296
|
+
| `-v`, `--version` | Print the version to stdout and exit 0 |
|
|
297
|
+
| `-h`, `--help` | Print usage, options and key environment variables, and exit 0 |
|
|
298
|
+
| _(none)_ | Start the MCP server on stdio — the normal mode, invoked by your MCP client |
|
|
299
|
+
|
|
300
|
+
An unrecognised argument is reported on stderr and exits 1, rather than starting
|
|
301
|
+
a server that would ignore it.
|
|
302
|
+
|
|
281
303
|
## Azure App Registration
|
|
282
304
|
|
|
283
305
|
> **First time with Azure?** The [Azure Setup Guide](docs/guides/azure-setup.md) covers everything from creating an account to your first authentication, including billing setup and common pitfalls.
|
|
@@ -446,7 +468,11 @@ npm run auth-server
|
|
|
446
468
|
|
|
447
469
|
### "Invalid client secret" (AADSTS7000215)
|
|
448
470
|
|
|
449
|
-
You're using the Secret **ID** instead of the Secret **Value**. Go to Azure Portal > Certificates & secrets and copy the **Value** column
|
|
471
|
+
You're using the Secret **ID** instead of the Secret **Value**. Go to Azure Portal > Certificates & secrets and copy the **Value** column into `OUTLOOK_CLIENT_SECRET`.
|
|
472
|
+
|
|
473
|
+
The Value is shown only once, when the secret is created — if you've navigated away it can't be read again, so create a new secret. An **expired** secret produces this same error, so check the Expires column too.
|
|
474
|
+
|
|
475
|
+
Since v3.11.0 the server detects this error and appends the explanation to Microsoft's original message, so you see both the raw error code and what to do about it.
|
|
450
476
|
|
|
451
477
|
### Authentication URL doesn't work
|
|
452
478
|
|
|
@@ -497,7 +523,7 @@ USE_TEST_MODE=true npm start
|
|
|
497
523
|
| [Getting Started](docs/how-to/getting-started/connect-outlook-to-claude.md) | Install, configure, and authenticate — start here |
|
|
498
524
|
| [Azure Setup Guide](docs/guides/azure-setup.md) | Azure account creation, app registration, permissions, and secrets |
|
|
499
525
|
| [How-To Guides](docs/how-to/index.md) | 29 practical guides for email, calendar, contacts, and settings |
|
|
500
|
-
| [Roadmap](ROADMAP.md) | Active milestones (v3.
|
|
526
|
+
| [Roadmap](ROADMAP.md) | Active milestones (v3.11.1, v3.8.x, v3.12.0+) and recent releases |
|
|
501
527
|
| [Troubleshooting & FAQ](docs/how-to/getting-started/verify-your-connection.md#common-connection-problems) | Common problems, re-authentication, and frequently asked questions |
|
|
502
528
|
| [Tools Reference](docs/quickrefs/tools-reference.md) | All 22 tools with parameters |
|
|
503
529
|
| [AI Agent Guide](docs/how-to/ai-agents/using-outlook-assistant-in-agents.md) | Tool selection and workflow patterns for AI agents |
|
|
@@ -506,7 +532,7 @@ Full documentation: [docs/](docs/README.md)
|
|
|
506
532
|
|
|
507
533
|
## Known Limitations
|
|
508
534
|
|
|
509
|
-
- **Personal account search**: Free-text `query` and the raw `searchExpression` (formerly `kqlQuery`) rely on Microsoft's `$search` API, which has limited support on personal Outlook.com accounts
|
|
535
|
+
- **Personal account search**: Free-text `query` and the raw `searchExpression` (formerly `kqlQuery`) rely on Microsoft's `$search` API, which has limited support on personal Outlook.com accounts. `query` mitigates this with progressive fallback (OData filters, boolean filters, then a client-side scan). Field-scoped `$search` (e.g. `subject:"…"`) is rejected outright there; since v3.10.0 `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried, but boolean operators, grouping, wildcards and other field prefixes are not — those still terminate with an explicit no-results rather than a silent broader search. Structured filters (`from`, `subject`, `to`, `receivedAfter`) remain the most direct route. Cross-folder search (`searchAllFolders: true`) returns a superset of inbox-only results.
|
|
510
536
|
- **Focused Inbox**: Only available on work/school Microsoft 365 accounts.
|
|
511
537
|
- **Shared mailboxes**: Require `Mail.Read.Shared` permission and a work/school account.
|
|
512
538
|
- **Meeting room search**: Requires `Place.Read.All` permission with admin consent (work/school accounts only).
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure AD (AADSTS) error translation.
|
|
3
|
+
*
|
|
4
|
+
* Microsoft's identity platform returns accurate but unfriendly errors: the
|
|
5
|
+
* error_description is a wall of prose ending in a correlation ID, and the
|
|
6
|
+
* single most common setup mistake — pasting the client secret's *ID* instead
|
|
7
|
+
* of its *Value* — surfaces only as `AADSTS7000215: Invalid client secret
|
|
8
|
+
* provided`. (#69)
|
|
9
|
+
*
|
|
10
|
+
* This module maps known codes to actionable remediation text. It only ever
|
|
11
|
+
* *adds* to the original message; the raw Azure error is always preserved so
|
|
12
|
+
* that searching for the code still works.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Known Azure error signatures and their remediation hints.
|
|
17
|
+
* Ordered most-specific first; every matching entry contributes a hint.
|
|
18
|
+
*/
|
|
19
|
+
const AUTH_ERROR_HINTS = [
|
|
20
|
+
{
|
|
21
|
+
// The Secret ID vs Secret Value mistake. First row of docs/troubleshooting.md.
|
|
22
|
+
test: /AADSTS7000215/i,
|
|
23
|
+
hint:
|
|
24
|
+
'Invalid client secret. The usual cause is pasting the **Secret ID** (a UUID like `a1b2c3d4-…`) instead of the **Secret Value** (a longer string with mixed case and symbols). ' +
|
|
25
|
+
'In Azure → App registrations → your app → Certificates & secrets → Client secrets, copy the *Value* column into `OUTLOOK_CLIENT_SECRET`. ' +
|
|
26
|
+
'The Secret Value is shown only once, when the secret is created — if you have navigated away it can no longer be read, so create a new secret. ' +
|
|
27
|
+
'An expired secret produces this same error, so check the Expires column too. ' +
|
|
28
|
+
'See docs/guides/azure-setup.md#4-create-a-client-secret.',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
test: /AADSTS9002331|personal.*account/i,
|
|
32
|
+
hint: 'This app registration appears to accept personal Microsoft accounts only. Set `OUTLOOK_AUTH_AUDIENCE=consumers` (or the correct tenant GUID) in your MCP env and retry.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
test: /AADSTS7000218|unauthorized_client|invalid_client/i,
|
|
36
|
+
// AADSTS7000215 is also delivered as error=invalid_client, but it means the
|
|
37
|
+
// secret is wrong, not that public client flows are disabled. Suppress the
|
|
38
|
+
// generic hint so the specific one above is not drowned out.
|
|
39
|
+
notWhen: /AADSTS7000215/i,
|
|
40
|
+
hint: "Enable 'Allow public client flows' under Azure → App registration → Authentication → Advanced settings, and add the `nativeclient` redirect URI (Mobile and desktop applications platform).",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
test: /AADSTS700016/i,
|
|
44
|
+
hint: 'The application was not found in this directory. Check `OUTLOOK_CLIENT_ID` matches the Application (client) ID in Azure, and that `OUTLOOK_AUTH_AUDIENCE` targets the right tenant.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
test: /invalid_grant|AADSTS700082|AADSTS50173/i,
|
|
48
|
+
hint: 'The refresh token is expired or has been revoked (refresh tokens last ~90 days). Re-authenticate with the `auth` tool: `action=authenticate`, then `action=device-code-complete`.',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** Normalise an Error or string into a searchable message. */
|
|
53
|
+
function toMessage(input) {
|
|
54
|
+
if (input === null || input === undefined) return '';
|
|
55
|
+
if (input instanceof Error) return input.message || String(input);
|
|
56
|
+
if (typeof input === 'object' && input.message) return String(input.message);
|
|
57
|
+
return String(input);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Return remediation hints for a Microsoft auth error.
|
|
62
|
+
* @param {Error|string|null|undefined} input - Error or error_description
|
|
63
|
+
* @returns {string[]} - Zero or more hints; empty when the error is unknown
|
|
64
|
+
*/
|
|
65
|
+
function getAuthErrorHints(input) {
|
|
66
|
+
const msg = toMessage(input);
|
|
67
|
+
if (!msg) return [];
|
|
68
|
+
return AUTH_ERROR_HINTS.filter(
|
|
69
|
+
(e) => e.test.test(msg) && !(e.notWhen && e.notWhen.test(msg))
|
|
70
|
+
).map((e) => e.hint);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Append any known remediation hints to an auth error message.
|
|
75
|
+
* Returns the message unchanged when nothing is known, so callers can use this
|
|
76
|
+
* unconditionally without polluting unrelated errors.
|
|
77
|
+
* @param {Error|string} input
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function describeAuthError(input) {
|
|
81
|
+
const msg = toMessage(input);
|
|
82
|
+
const hints = getAuthErrorHints(msg);
|
|
83
|
+
if (!hints.length) return msg;
|
|
84
|
+
return [msg, '', 'Suggested fixes:', ...hints.map((h) => `- ${h}`)].join(
|
|
85
|
+
'\n'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { getAuthErrorHints, describeAuthError, AUTH_ERROR_HINTS };
|
package/auth/oauth-server.js
CHANGED
|
@@ -38,7 +38,8 @@ const templates = {
|
|
|
38
38
|
<body style="font-family: Arial, sans-serif; text-align: center; margin-top: 50px;">
|
|
39
39
|
<h1 style="color: #e74c3c;">❌ Token Exchange Failed</h1>
|
|
40
40
|
<p>Failed to exchange authorization code for access token.</p>
|
|
41
|
-
<p><strong>Error:</strong
|
|
41
|
+
<p><strong>Error:</strong></p>
|
|
42
|
+
<pre style="white-space: pre-wrap; text-align: left; display: inline-block; max-width: 40em; font-family: inherit;">${escapeHtml(error instanceof Error ? error.message : String(error))}</pre>
|
|
42
43
|
<p>You can close this window and try again.</p>
|
|
43
44
|
</body>
|
|
44
45
|
</html>`,
|
|
@@ -227,7 +228,7 @@ function setupOAuthRoutes(
|
|
|
227
228
|
module.exports = {
|
|
228
229
|
setupOAuthRoutes,
|
|
229
230
|
createAuthConfig,
|
|
230
|
-
//
|
|
231
|
-
|
|
231
|
+
// Exported so the rendered error pages can be asserted on directly (#69).
|
|
232
|
+
templates,
|
|
232
233
|
};
|
|
233
234
|
// Adding a newline at the end of the file as requested by Gemini Code Assist
|
package/auth/token-storage.js
CHANGED
|
@@ -3,6 +3,7 @@ const fsSync = require('fs');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const https = require('https');
|
|
5
5
|
const querystring = require('querystring');
|
|
6
|
+
const { describeAuthError } = require('./auth-errors');
|
|
6
7
|
|
|
7
8
|
class TokenStorage {
|
|
8
9
|
constructor(config) {
|
|
@@ -131,16 +132,22 @@ class TokenStorage {
|
|
|
131
132
|
return await this.refreshAccessToken();
|
|
132
133
|
} catch (refreshError) {
|
|
133
134
|
console.error('Failed to refresh access token:', refreshError);
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
// Drop the in-memory tokens so callers re-authenticate. The save
|
|
136
|
+
// below is intentionally a no-op — `_saveTokensToFile` returns early
|
|
137
|
+
// when `tokens` is null — which is the behaviour we want: a transient
|
|
138
|
+
// network failure must not erase a still-valid refresh token from
|
|
139
|
+
// disk. The next process start reloads it and retries. (#72)
|
|
140
|
+
this.tokens = null;
|
|
141
|
+
await this._saveTokensToFile();
|
|
136
142
|
return null;
|
|
137
143
|
}
|
|
138
144
|
} else {
|
|
139
145
|
console.warn(
|
|
140
146
|
'No refresh token available. Cannot refresh access token.'
|
|
141
147
|
);
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
// Same as above: clears memory, leaves the file alone. (#72)
|
|
149
|
+
this.tokens = null;
|
|
150
|
+
await this._saveTokensToFile();
|
|
144
151
|
return null;
|
|
145
152
|
}
|
|
146
153
|
}
|
|
@@ -226,8 +233,10 @@ class TokenStorage {
|
|
|
226
233
|
console.error('Error refreshing token:', responseBody);
|
|
227
234
|
reject(
|
|
228
235
|
new Error(
|
|
229
|
-
|
|
230
|
-
|
|
236
|
+
describeAuthError(
|
|
237
|
+
responseBody.error_description ||
|
|
238
|
+
`Token refresh failed with status ${res.statusCode}`
|
|
239
|
+
)
|
|
231
240
|
)
|
|
232
241
|
);
|
|
233
242
|
}
|
|
@@ -320,8 +329,10 @@ class TokenStorage {
|
|
|
320
329
|
);
|
|
321
330
|
reject(
|
|
322
331
|
new Error(
|
|
323
|
-
|
|
324
|
-
|
|
332
|
+
describeAuthError(
|
|
333
|
+
responseBody.error_description ||
|
|
334
|
+
`Token exchange failed with status ${res.statusCode}`
|
|
335
|
+
)
|
|
325
336
|
)
|
|
326
337
|
);
|
|
327
338
|
}
|
package/auth/tools.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Authentication-related tools for the Outlook Assistant server
|
|
3
3
|
*/
|
|
4
4
|
const config = require('../config');
|
|
5
|
+
const { getAuthErrorHints } = require('./auth-errors');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const path = require('path');
|
|
7
8
|
const tokenManager = require('./token-manager');
|
|
@@ -264,20 +265,10 @@ async function handleDeviceCodeAuth() {
|
|
|
264
265
|
function buildDeviceCodeErrorResponse(error) {
|
|
265
266
|
const msg = (error && error.message) || String(error);
|
|
266
267
|
const code = error && error.code;
|
|
267
|
-
|
|
268
|
+
// Shared AADSTS hint table (#69) so this path and the token-endpoint paths in
|
|
269
|
+
// token-storage.js cannot drift apart.
|
|
270
|
+
const hints = getAuthErrorHints(msg);
|
|
268
271
|
|
|
269
|
-
if (/AADSTS9002331/i.test(msg) || /personal.*account/i.test(msg)) {
|
|
270
|
-
hints.push(
|
|
271
|
-
'This app registration appears to accept personal Microsoft accounts only. Set `OUTLOOK_AUTH_AUDIENCE=consumers` (or the correct tenant GUID) in your MCP env and retry.'
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
if (
|
|
275
|
-
/invalid_client|unauthorized_client|AADSTS7000218|AADSTS700016/i.test(msg)
|
|
276
|
-
) {
|
|
277
|
-
hints.push(
|
|
278
|
-
"Enable 'Allow public client flows' under Azure → App registration → Authentication → Advanced settings, and add the `nativeclient` redirect URI (Mobile and desktop applications platform)."
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
272
|
if (
|
|
282
273
|
code === 'ENOTFOUND' ||
|
|
283
274
|
code === 'ETIMEDOUT' ||
|
package/email/index.js
CHANGED
|
@@ -73,7 +73,7 @@ const emailTools = [
|
|
|
73
73
|
searchExpression: {
|
|
74
74
|
type: 'string',
|
|
75
75
|
description:
|
|
76
|
-
'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:
|
|
76
|
+
'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.',
|
|
77
77
|
},
|
|
78
78
|
kqlQuery: {
|
|
79
79
|
type: 'string',
|
package/email/search.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
DEFAULT_LIMITS,
|
|
14
14
|
} = require('../utils/response-formatter');
|
|
15
15
|
const { getEmailFields } = require('../utils/field-presets');
|
|
16
|
+
const { escapeODataString } = require('../utils/odata-helpers');
|
|
16
17
|
|
|
17
18
|
// Upper bound on how many recent messages the client-side fallback scans
|
|
18
19
|
// before giving up. Deliberately DECOUPLED from the requested result count so
|
|
@@ -71,9 +72,16 @@ async function handleSearchEmails(args) {
|
|
|
71
72
|
const kqlQuery =
|
|
72
73
|
(args.searchExpression || '').trim() || (args.kqlQuery || '').trim();
|
|
73
74
|
|
|
74
|
-
// Select fields based on verbosity
|
|
75
|
+
// Select fields based on verbosity — but never at the cost of correctness.
|
|
76
|
+
// When more than one search term is supplied, the ladder may satisfy one
|
|
77
|
+
// server-side and narrow by the rest locally (#229), and those matchers read
|
|
78
|
+
// `toRecipients` and `bodyPreview`, which the `list` preset omits. Without
|
|
79
|
+
// this, a `from`+`to` search would drop every row at default verbosity and
|
|
80
|
+
// return "No emails found" — making `outputVerbosity`, a presentation
|
|
81
|
+
// parameter, decide which messages are found.
|
|
82
|
+
const searchTermCount = [query, from, to, subject].filter(Boolean).length;
|
|
75
83
|
const selectFields = getEmailFields(
|
|
76
|
-
verbosity === VERBOSITY.FULL ? 'search' : 'list'
|
|
84
|
+
verbosity === VERBOSITY.FULL || searchTermCount > 1 ? 'search' : 'list'
|
|
77
85
|
);
|
|
78
86
|
|
|
79
87
|
try {
|
|
@@ -102,7 +110,12 @@ async function handleSearchEmails(args) {
|
|
|
102
110
|
|
|
103
111
|
// Label the scope accurately — a cross-folder search is not "inbox". (#169)
|
|
104
112
|
const scopeLabel = searchAllFolders ? 'all folders' : folder;
|
|
105
|
-
return formatSearchResults(
|
|
113
|
+
return formatSearchResults(
|
|
114
|
+
response,
|
|
115
|
+
scopeLabel,
|
|
116
|
+
verbosity,
|
|
117
|
+
searchAllFolders
|
|
118
|
+
);
|
|
106
119
|
} catch (error) {
|
|
107
120
|
// Handle authentication errors
|
|
108
121
|
if (error.message === 'Authentication required') {
|
|
@@ -128,6 +141,134 @@ async function handleSearchEmails(args) {
|
|
|
128
141
|
}
|
|
129
142
|
}
|
|
130
143
|
|
|
144
|
+
/** Field prefixes a `searchExpression` can be translated into OData filters. */
|
|
145
|
+
const TRANSLATABLE_KQL_FIELDS = new Set(['from', 'to', 'subject']);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Parse a `searchExpression` that consists purely of recognised field-scoped
|
|
149
|
+
* terms, e.g. `from:info@example.com` or `subject:"Quarterly Report"`. (#217)
|
|
150
|
+
*
|
|
151
|
+
* Unscoped `$search` works fine on personal Outlook.com accounts; only the
|
|
152
|
+
* field-scoped forms come back empty. Those same messages are reachable
|
|
153
|
+
* through the OData filters the ladder already builds, so a recognised
|
|
154
|
+
* expression can be translated and retried rather than reported as absent.
|
|
155
|
+
*
|
|
156
|
+
* This parser is deliberately strict, and returns null for anything it does
|
|
157
|
+
* not fully understand — free text, `AND`/`OR`/`NOT`, parentheses, unknown
|
|
158
|
+
* prefixes, a repeated field, or free text mixed in with a scoped term. That
|
|
159
|
+
* keeps the terminal no-fallthrough behaviour for every expression whose
|
|
160
|
+
* meaning we cannot reproduce exactly, which is what #169 V37-F-1 shipped to
|
|
161
|
+
* guarantee. Translating a half-understood expression would reintroduce it.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} expression - The trimmed raw search expression
|
|
164
|
+
* @returns {object|null} - Search terms to retry with, or null if not translatable
|
|
165
|
+
*/
|
|
166
|
+
function parseFieldScopedExpression(expression) {
|
|
167
|
+
const trimmed = (expression || '').trim();
|
|
168
|
+
if (!trimmed) return null;
|
|
169
|
+
|
|
170
|
+
const terms = {};
|
|
171
|
+
let i = 0;
|
|
172
|
+
|
|
173
|
+
while (i < trimmed.length) {
|
|
174
|
+
while (i < trimmed.length && /\s/.test(trimmed[i])) i++;
|
|
175
|
+
if (i >= trimmed.length) break;
|
|
176
|
+
|
|
177
|
+
// Every token must be `field:value`. A token without a colon is free
|
|
178
|
+
// text; a colon further along belongs to a later token, and the slice
|
|
179
|
+
// then carries whitespace or an operator, which fails the field check.
|
|
180
|
+
const colon = trimmed.indexOf(':', i);
|
|
181
|
+
if (colon === -1) return null;
|
|
182
|
+
|
|
183
|
+
const field = trimmed.slice(i, colon).toLowerCase();
|
|
184
|
+
if (!TRANSLATABLE_KQL_FIELDS.has(field)) return null;
|
|
185
|
+
if (terms[field]) return null; // repeated field — ambiguous, don't guess
|
|
186
|
+
i = colon + 1;
|
|
187
|
+
|
|
188
|
+
let value;
|
|
189
|
+
if (trimmed[i] === '"') {
|
|
190
|
+
const end = trimmed.indexOf('"', i + 1);
|
|
191
|
+
if (end === -1) return null; // unbalanced quote
|
|
192
|
+
value = trimmed.slice(i + 1, end);
|
|
193
|
+
i = end + 1;
|
|
194
|
+
// A quoted value must be followed by whitespace or end-of-string.
|
|
195
|
+
if (i < trimmed.length && !/\s/.test(trimmed[i])) return null;
|
|
196
|
+
} else {
|
|
197
|
+
let end = i;
|
|
198
|
+
while (end < trimmed.length && !/\s/.test(trimmed[end])) end++;
|
|
199
|
+
value = trimmed.slice(i, end);
|
|
200
|
+
i = end;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!value) return null;
|
|
204
|
+
// Wildcards and grouping mean something in KQL that an OData `eq` or
|
|
205
|
+
// `contains` does not reproduce. Rather than translate them literally,
|
|
206
|
+
// decline the whole expression.
|
|
207
|
+
if (/[()*]/.test(value)) return null;
|
|
208
|
+
terms[field] = value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return Object.keys(terms).length > 0 ? terms : null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Retry a field-scoped `searchExpression` as OData filters, down the normal
|
|
216
|
+
* ladder. Returns null when the expression is not translatable, which keeps
|
|
217
|
+
* the terminal no-fallthrough behaviour #169 V37-F-1 shipped. (#217)
|
|
218
|
+
*
|
|
219
|
+
* @param {object} ctx - Everything the retry needs from the raw-KQL branch
|
|
220
|
+
* @returns {Promise<object|null>} - A merged response, or null if untranslatable
|
|
221
|
+
*/
|
|
222
|
+
async function retryFieldScopedExpression(ctx) {
|
|
223
|
+
const {
|
|
224
|
+
endpoint,
|
|
225
|
+
accessToken,
|
|
226
|
+
trimmedKql,
|
|
227
|
+
kqlForSearch,
|
|
228
|
+
searchTerms,
|
|
229
|
+
filterTerms,
|
|
230
|
+
maxCount,
|
|
231
|
+
selectFields,
|
|
232
|
+
searchAttempts,
|
|
233
|
+
} = ctx;
|
|
234
|
+
|
|
235
|
+
const translated = parseFieldScopedExpression(trimmedKql);
|
|
236
|
+
if (!translated) return null;
|
|
237
|
+
|
|
238
|
+
console.error(
|
|
239
|
+
`Retrying field-scoped searchExpression as OData filters: ${JSON.stringify(translated)}`
|
|
240
|
+
);
|
|
241
|
+
const retry = await progressiveSearch(
|
|
242
|
+
endpoint,
|
|
243
|
+
accessToken,
|
|
244
|
+
translated,
|
|
245
|
+
filterTerms,
|
|
246
|
+
maxCount,
|
|
247
|
+
selectFields
|
|
248
|
+
);
|
|
249
|
+
const retryCount = retry.value?.length || 0;
|
|
250
|
+
const strategies = [
|
|
251
|
+
...searchAttempts,
|
|
252
|
+
...(retry._searchInfo?.strategies || []),
|
|
253
|
+
// Last, so finalStrategy names the translation rather than whichever rung
|
|
254
|
+
// of the ladder happened to answer it.
|
|
255
|
+
'raw-kql-translated',
|
|
256
|
+
];
|
|
257
|
+
retry._searchInfo = {
|
|
258
|
+
...retry._searchInfo,
|
|
259
|
+
attemptsCount: strategies.length,
|
|
260
|
+
strategies,
|
|
261
|
+
// Report what the caller actually asked for, not the rewrite.
|
|
262
|
+
originalTerms: searchTerms,
|
|
263
|
+
filterTerms,
|
|
264
|
+
kqlApplied: kqlForSearch,
|
|
265
|
+
kqlTranslatedTo: translated,
|
|
266
|
+
appliedTerms: ['kqlQuery'],
|
|
267
|
+
noResults: retryCount === 0,
|
|
268
|
+
};
|
|
269
|
+
return retry;
|
|
270
|
+
}
|
|
271
|
+
|
|
131
272
|
/**
|
|
132
273
|
* Execute a search with progressively simpler fallback strategies
|
|
133
274
|
* @param {string} endpoint - API endpoint
|
|
@@ -158,27 +299,42 @@ async function progressiveSearch(
|
|
|
158
299
|
// would drop the user's filter and return unrelated recent emails
|
|
159
300
|
// with a misleading "combined-search" strategy line. (#169)
|
|
160
301
|
if (searchTerms.kqlQuery) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
302
|
+
// Pass the user's KQL through as-is. The user is responsible for
|
|
303
|
+
// their own phrase quoting (e.g. `subject:"foo bar"`); we do NOT
|
|
304
|
+
// auto-wrap, which previously produced broken nested quotes like
|
|
305
|
+
// `"subject:"foo bar""` on Graph $search and silently returned
|
|
306
|
+
// recent unfiltered messages. (#169 V37-F-1)
|
|
307
|
+
const trimmedKql = searchTerms.kqlQuery.trim();
|
|
308
|
+
const alreadyQuoted =
|
|
309
|
+
trimmedKql.startsWith('"') && trimmedKql.endsWith('"');
|
|
310
|
+
const looksLikeExpression =
|
|
311
|
+
trimmedKql.includes(':') || /\s/.test(trimmedKql);
|
|
312
|
+
// Already-quoted phrases and KQL-looking expressions (field syntax
|
|
313
|
+
// or multi-word) are passed through as-is; only bare single tokens
|
|
314
|
+
// are wrapped so Graph treats them as phrase searches.
|
|
315
|
+
let kqlForSearch;
|
|
316
|
+
if (alreadyQuoted || looksLikeExpression) {
|
|
317
|
+
kqlForSearch = trimmedKql;
|
|
318
|
+
} else {
|
|
319
|
+
kqlForSearch = `"${trimmedKql}"`;
|
|
320
|
+
}
|
|
181
321
|
|
|
322
|
+
// Graph rejects field-scoped expressions outright on personal accounts
|
|
323
|
+
// ("Syntax error: character ':' is not valid"), so the translated retry
|
|
324
|
+
// has to be reachable from the catch as well as the empty-result path.
|
|
325
|
+
const translationContext = {
|
|
326
|
+
endpoint,
|
|
327
|
+
accessToken,
|
|
328
|
+
trimmedKql,
|
|
329
|
+
kqlForSearch,
|
|
330
|
+
searchTerms,
|
|
331
|
+
filterTerms,
|
|
332
|
+
maxCount,
|
|
333
|
+
selectFields,
|
|
334
|
+
searchAttempts,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
try {
|
|
182
338
|
console.error(`Attempting raw KQL search: ${kqlForSearch}`);
|
|
183
339
|
searchAttempts.push('raw-kql');
|
|
184
340
|
|
|
@@ -205,19 +361,49 @@ async function progressiveSearch(
|
|
|
205
361
|
originalTerms: searchTerms,
|
|
206
362
|
filterTerms: filterTerms,
|
|
207
363
|
kqlApplied: kqlForSearch,
|
|
364
|
+
appliedTerms: ['kqlQuery'],
|
|
208
365
|
// noResults flips on the helpful "Suggestions" block in the
|
|
209
366
|
// formatter — without it, an empty kqlQuery result would render
|
|
210
367
|
// the bare "No emails found matching your search criteria" line
|
|
211
368
|
// with no guidance.
|
|
212
369
|
noResults: matched === 0,
|
|
213
370
|
};
|
|
214
|
-
//
|
|
371
|
+
// Empty-but-successful is the other #217 shape: some tenants answer a
|
|
372
|
+
// field-scoped expression with 0 rather than a 400.
|
|
373
|
+
if (matched === 0) {
|
|
374
|
+
// Guarded like the error path below: if the translated ladder threw,
|
|
375
|
+
// an unguarded call here would land in the catch and run the whole
|
|
376
|
+
// retry a second time.
|
|
377
|
+
try {
|
|
378
|
+
const retry = await retryFieldScopedExpression(translationContext);
|
|
379
|
+
if (retry) return retry;
|
|
380
|
+
} catch (retryError) {
|
|
381
|
+
console.error(`Translated retry failed: ${retryError.message}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Otherwise always return — never silently fall through to a path that
|
|
215
386
|
// would ignore kqlQuery and return unrelated emails.
|
|
216
387
|
return response;
|
|
217
388
|
} catch (error) {
|
|
218
389
|
console.error(`Raw KQL search failed: ${error.message}`);
|
|
219
|
-
// Surface the failure rather than masking it with unrelated results.
|
|
220
390
|
searchAttempts.push('raw-kql-error');
|
|
391
|
+
|
|
392
|
+
// A field-scoped expression is what Graph rejects here on personal
|
|
393
|
+
// accounts. Translating and retrying beats reporting mail that plainly
|
|
394
|
+
// exists as absent — the same messages come back from the equivalent
|
|
395
|
+
// OData filter. Anything untranslatable still surfaces the error. (#217)
|
|
396
|
+
try {
|
|
397
|
+
const retry = await retryFieldScopedExpression(translationContext);
|
|
398
|
+
if (retry) {
|
|
399
|
+
retry._searchInfo.kqlError = error.message;
|
|
400
|
+
return retry;
|
|
401
|
+
}
|
|
402
|
+
} catch (retryError) {
|
|
403
|
+
console.error(`Translated retry also failed: ${retryError.message}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Surface the failure rather than masking it with unrelated results.
|
|
221
407
|
return {
|
|
222
408
|
value: [],
|
|
223
409
|
_searchInfo: {
|
|
@@ -226,6 +412,7 @@ async function progressiveSearch(
|
|
|
226
412
|
originalTerms: searchTerms,
|
|
227
413
|
filterTerms: filterTerms,
|
|
228
414
|
kqlError: error.message,
|
|
415
|
+
appliedTerms: ['kqlQuery'],
|
|
229
416
|
noResults: true,
|
|
230
417
|
},
|
|
231
418
|
};
|
|
@@ -273,6 +460,8 @@ async function progressiveSearch(
|
|
|
273
460
|
strategies: searchAttempts,
|
|
274
461
|
originalTerms: searchTerms,
|
|
275
462
|
filterTerms: filterTerms,
|
|
463
|
+
// The combined $filter carried every supplied term. (#229)
|
|
464
|
+
appliedTerms: SEARCH_TERM_KEYS.filter((t) => searchTerms[t]),
|
|
276
465
|
};
|
|
277
466
|
return response;
|
|
278
467
|
}
|
|
@@ -311,7 +500,9 @@ async function progressiveSearch(
|
|
|
311
500
|
simplifiedParams.$filter = buildToFilter(searchTerms[term]);
|
|
312
501
|
} else if (term === 'subject') {
|
|
313
502
|
// Use $filter with contains() — $search silently fails on personal MS accounts
|
|
314
|
-
simplifiedParams.$filter = `contains(subject, '${
|
|
503
|
+
simplifiedParams.$filter = `contains(subject, '${escapeODataString(
|
|
504
|
+
searchTerms[term]
|
|
505
|
+
)}')`;
|
|
315
506
|
} else if (term === 'query') {
|
|
316
507
|
// On personal accounts, $search fails with 503. Use $filter with
|
|
317
508
|
// contains(subject) as a best-effort fallback for free-text queries.
|
|
@@ -324,7 +515,7 @@ async function progressiveSearch(
|
|
|
324
515
|
.split(/\s+/)
|
|
325
516
|
.filter(Boolean);
|
|
326
517
|
simplifiedParams.$filter = queryWords
|
|
327
|
-
.map((w) => `contains(subject, '${w
|
|
518
|
+
.map((w) => `contains(subject, '${escapeODataString(w)}')`)
|
|
328
519
|
.join(' and ');
|
|
329
520
|
}
|
|
330
521
|
|
|
@@ -339,16 +530,39 @@ async function progressiveSearch(
|
|
|
339
530
|
maxCount
|
|
340
531
|
);
|
|
341
532
|
if (response.value && response.value.length > 0) {
|
|
533
|
+
// This $filter carried only `term`. Apply every other supplied search
|
|
534
|
+
// term locally before returning — otherwise we hand back the
|
|
535
|
+
// single-term superset while claiming the filter was applied. (#229)
|
|
536
|
+
const { matched, secondaryApplied } = applySecondarySearchTerms(
|
|
537
|
+
response.value,
|
|
538
|
+
searchTerms,
|
|
539
|
+
term
|
|
540
|
+
);
|
|
541
|
+
if (matched.length > 0) {
|
|
542
|
+
const narrowing =
|
|
543
|
+
secondaryApplied.length > 0
|
|
544
|
+
? ` after local ${secondaryApplied.join(', ')} narrowing of ${response.value.length}`
|
|
545
|
+
: '';
|
|
546
|
+
console.error(
|
|
547
|
+
`Search with ${term} successful: found ${matched.length} results${narrowing}`
|
|
548
|
+
);
|
|
549
|
+
narrowResponse(response, matched);
|
|
550
|
+
response._searchInfo = {
|
|
551
|
+
attemptsCount: searchAttempts.length,
|
|
552
|
+
strategies: searchAttempts,
|
|
553
|
+
originalTerms: searchTerms,
|
|
554
|
+
filterTerms: filterTerms,
|
|
555
|
+
appliedTerms: [term, ...secondaryApplied],
|
|
556
|
+
...(secondaryApplied.length > 0 && {
|
|
557
|
+
clientSideTerms: secondaryApplied,
|
|
558
|
+
}),
|
|
559
|
+
};
|
|
560
|
+
return response;
|
|
561
|
+
}
|
|
562
|
+
recordNarrowingMiss(scanState, response.value.length);
|
|
342
563
|
console.error(
|
|
343
|
-
`Search with ${term}
|
|
564
|
+
`Search with ${term} found ${response.value.length} results, but none also satisfied ${secondaryApplied.join(', ')} — continuing`
|
|
344
565
|
);
|
|
345
|
-
response._searchInfo = {
|
|
346
|
-
attemptsCount: searchAttempts.length,
|
|
347
|
-
strategies: searchAttempts,
|
|
348
|
-
originalTerms: searchTerms,
|
|
349
|
-
filterTerms: filterTerms,
|
|
350
|
-
};
|
|
351
|
-
return response;
|
|
352
566
|
}
|
|
353
567
|
} catch (error) {
|
|
354
568
|
console.error(`Search with ${term} failed: ${error.message}`);
|
|
@@ -418,13 +632,32 @@ async function progressiveSearch(
|
|
|
418
632
|
console.error(
|
|
419
633
|
`Boolean filter search found ${response.value?.length || 0} results`
|
|
420
634
|
);
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
635
|
+
// This step applied only the boolean/date filters. Narrow by the
|
|
636
|
+
// caller's search terms rather than returning "every unread email" as
|
|
637
|
+
// though it were "every unread email from X". (#229)
|
|
638
|
+
const { matched, secondaryApplied } = applySecondarySearchTerms(
|
|
639
|
+
response.value || [],
|
|
640
|
+
searchTerms,
|
|
641
|
+
null
|
|
642
|
+
);
|
|
643
|
+
if (matched.length > 0 || secondaryApplied.length === 0) {
|
|
644
|
+
narrowResponse(response, matched);
|
|
645
|
+
response._searchInfo = {
|
|
646
|
+
attemptsCount: searchAttempts.length,
|
|
647
|
+
strategies: searchAttempts,
|
|
648
|
+
originalTerms: searchTerms,
|
|
649
|
+
filterTerms: filterTerms,
|
|
650
|
+
appliedTerms: secondaryApplied,
|
|
651
|
+
...(secondaryApplied.length > 0 && {
|
|
652
|
+
clientSideTerms: secondaryApplied,
|
|
653
|
+
}),
|
|
654
|
+
};
|
|
655
|
+
return response;
|
|
656
|
+
}
|
|
657
|
+
recordNarrowingMiss(scanState, response.value.length);
|
|
658
|
+
console.error(
|
|
659
|
+
`Boolean filter search found ${response.value.length} results, but none satisfied ${secondaryApplied.join(', ')} — continuing`
|
|
660
|
+
);
|
|
428
661
|
} catch (error) {
|
|
429
662
|
console.error(`Boolean filter search failed: ${error.message}`);
|
|
430
663
|
// Retry without $orderby if it was the issue
|
|
@@ -443,13 +676,33 @@ async function progressiveSearch(
|
|
|
443
676
|
retryParams,
|
|
444
677
|
maxCount
|
|
445
678
|
);
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
679
|
+
const { matched, secondaryApplied } = applySecondarySearchTerms(
|
|
680
|
+
response.value || [],
|
|
681
|
+
searchTerms,
|
|
682
|
+
null
|
|
683
|
+
);
|
|
684
|
+
// Same guard as the primary boolean path above: an empty set here
|
|
685
|
+
// means the search terms went unsatisfied, so fall through to the
|
|
686
|
+
// no-results rung rather than returning 0 with filterApplied true
|
|
687
|
+
// and no guidance.
|
|
688
|
+
if (matched.length > 0 || secondaryApplied.length === 0) {
|
|
689
|
+
narrowResponse(response, matched);
|
|
690
|
+
response._searchInfo = {
|
|
691
|
+
attemptsCount: searchAttempts.length,
|
|
692
|
+
strategies: searchAttempts,
|
|
693
|
+
originalTerms: searchTerms,
|
|
694
|
+
filterTerms: filterTerms,
|
|
695
|
+
appliedTerms: secondaryApplied,
|
|
696
|
+
...(secondaryApplied.length > 0 && {
|
|
697
|
+
clientSideTerms: secondaryApplied,
|
|
698
|
+
}),
|
|
699
|
+
};
|
|
700
|
+
return response;
|
|
701
|
+
}
|
|
702
|
+
recordNarrowingMiss(scanState, response.value.length);
|
|
703
|
+
console.error(
|
|
704
|
+
`Boolean filter retry found ${response.value.length} results, but none satisfied ${secondaryApplied.join(', ')} — continuing`
|
|
705
|
+
);
|
|
453
706
|
} catch (retryError) {
|
|
454
707
|
console.error(
|
|
455
708
|
`Boolean filter retry also failed: ${retryError.message}`
|
|
@@ -489,6 +742,11 @@ async function progressiveSearch(
|
|
|
489
742
|
scanLimit: scanState.scanLimit,
|
|
490
743
|
truncated: scanState.truncated,
|
|
491
744
|
}),
|
|
745
|
+
// A local narrowing pass that matched nothing looked only at the page
|
|
746
|
+
// the winning filter returned, so say how much that was. (#229)
|
|
747
|
+
...(scanState.narrowedCandidates !== undefined && {
|
|
748
|
+
narrowedCandidates: scanState.narrowedCandidates,
|
|
749
|
+
}),
|
|
492
750
|
},
|
|
493
751
|
};
|
|
494
752
|
}
|
|
@@ -546,11 +804,13 @@ function buildFromFilter(val) {
|
|
|
546
804
|
if (type === 'domain') {
|
|
547
805
|
// Use contains() — endswith() not supported on personal accounts
|
|
548
806
|
const domain = val.startsWith('@') ? val : `@${val}`;
|
|
549
|
-
return `contains(from/emailAddress/address, '${
|
|
807
|
+
return `contains(from/emailAddress/address, '${escapeODataString(
|
|
808
|
+
domain.substring(1)
|
|
809
|
+
)}')`;
|
|
550
810
|
} else if (type === 'email') {
|
|
551
|
-
return `from/emailAddress/address eq '${val}'`;
|
|
811
|
+
return `from/emailAddress/address eq '${escapeODataString(val)}'`;
|
|
552
812
|
}
|
|
553
|
-
return `contains(from/emailAddress/name, '${val}')`;
|
|
813
|
+
return `contains(from/emailAddress/name, '${escapeODataString(val)}')`;
|
|
554
814
|
}
|
|
555
815
|
|
|
556
816
|
/**
|
|
@@ -563,11 +823,17 @@ function buildToFilter(val) {
|
|
|
563
823
|
if (type === 'domain') {
|
|
564
824
|
const domain = val.startsWith('@') ? val : `@${val}`;
|
|
565
825
|
// Use contains() — endswith() not supported on personal accounts
|
|
566
|
-
return `toRecipients/any(r: contains(r/emailAddress/address, '${
|
|
826
|
+
return `toRecipients/any(r: contains(r/emailAddress/address, '${escapeODataString(
|
|
827
|
+
domain.substring(1)
|
|
828
|
+
)}'))`;
|
|
567
829
|
} else if (type === 'email') {
|
|
568
|
-
return `toRecipients/any(r: r/emailAddress/address eq '${
|
|
830
|
+
return `toRecipients/any(r: r/emailAddress/address eq '${escapeODataString(
|
|
831
|
+
val
|
|
832
|
+
)}')`;
|
|
569
833
|
}
|
|
570
|
-
return `toRecipients/any(r: contains(r/emailAddress/name, '${
|
|
834
|
+
return `toRecipients/any(r: contains(r/emailAddress/name, '${escapeODataString(
|
|
835
|
+
val
|
|
836
|
+
)}'))`;
|
|
571
837
|
}
|
|
572
838
|
|
|
573
839
|
/**
|
|
@@ -621,6 +887,150 @@ function filterQueryClientSide(messages, queryText) {
|
|
|
621
887
|
});
|
|
622
888
|
}
|
|
623
889
|
|
|
890
|
+
/** Relabel internal keys to the caller-facing param names. (#169) */
|
|
891
|
+
const FILTER_LABELS = { kqlQuery: 'searchExpression' };
|
|
892
|
+
|
|
893
|
+
/** Every filter key a caller can supply, including the terminal raw-KQL one. */
|
|
894
|
+
const SEARCH_FILTER_KEYS = ['from', 'to', 'subject', 'query', 'kqlQuery'];
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* The search terms a caller can supply, in ladder-priority order. `kqlQuery`
|
|
898
|
+
* is handled by the terminal raw-KQL branch and never mixes with these.
|
|
899
|
+
*/
|
|
900
|
+
const SEARCH_TERM_KEYS = ['from', 'to', 'subject', 'query'];
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Client-side matcher for a `from` value. Mirrors filterToClientSide, matching
|
|
904
|
+
* either the sender address or the display name. (#229)
|
|
905
|
+
* @param {Array} messages - Messages to filter
|
|
906
|
+
* @param {string} fromValue - The from filter value
|
|
907
|
+
* @returns {Array} - Matching messages
|
|
908
|
+
*/
|
|
909
|
+
function filterFromClientSide(messages, fromValue) {
|
|
910
|
+
// Mirror buildFromFilter branch for branch. A plain substring test over both
|
|
911
|
+
// address and name diverges from the server in both directions: it misses
|
|
912
|
+
// `@example.com` against `alerts@mail.example.com` (the server strips the
|
|
913
|
+
// leading @ and does contains), and it wrongly keeps `bbob@x.com` for
|
|
914
|
+
// `bob@x.com` (the server uses eq).
|
|
915
|
+
const type = classifyEmailFilter(fromValue);
|
|
916
|
+
|
|
917
|
+
if (type === 'domain') {
|
|
918
|
+
const domain = (
|
|
919
|
+
fromValue.startsWith('@') ? fromValue.slice(1) : fromValue
|
|
920
|
+
).toLowerCase();
|
|
921
|
+
return messages.filter((m) =>
|
|
922
|
+
(m.from?.emailAddress?.address || '').toLowerCase().includes(domain)
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (type === 'email') {
|
|
927
|
+
const needle = fromValue.toLowerCase();
|
|
928
|
+
return messages.filter(
|
|
929
|
+
(m) => (m.from?.emailAddress?.address || '').toLowerCase() === needle
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const needle = fromValue.toLowerCase();
|
|
934
|
+
return messages.filter((m) =>
|
|
935
|
+
(m.from?.emailAddress?.name || '').toLowerCase().includes(needle)
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Client-side matcher for a `subject` value — the local equivalent of the
|
|
941
|
+
* server-side `contains(subject, '…')`. (#229)
|
|
942
|
+
* @param {Array} messages - Messages to filter
|
|
943
|
+
* @param {string} subjectValue - The subject filter value
|
|
944
|
+
* @returns {Array} - Matching messages
|
|
945
|
+
*/
|
|
946
|
+
function filterSubjectClientSide(messages, subjectValue) {
|
|
947
|
+
const needle = subjectValue.toLowerCase().trim();
|
|
948
|
+
if (!needle) return messages;
|
|
949
|
+
return messages.filter((m) =>
|
|
950
|
+
(m.subject || '').toLowerCase().includes(needle)
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const CLIENT_SIDE_TERM_MATCHERS = {
|
|
955
|
+
from: filterFromClientSide,
|
|
956
|
+
to: filterToClientSide,
|
|
957
|
+
subject: filterSubjectClientSide,
|
|
958
|
+
query: filterQueryClientSide,
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Narrow a result set by every supplied search term that the winning strategy
|
|
963
|
+
* did NOT apply server-side. (#229)
|
|
964
|
+
*
|
|
965
|
+
* Steps 2 and 3 of the ladder each satisfy at most one search term — step 2
|
|
966
|
+
* returns on the first single term that yields results, and step 3 applies
|
|
967
|
+
* only the boolean/date filters. Both used to return that set verbatim, so
|
|
968
|
+
* `from=X` + `subject=Y` handed back every email from X while
|
|
969
|
+
* `searchMetadata.filterApplied` still said `true`. For an AI caller asking
|
|
970
|
+
* "find the email from X about Y", a confident superset is strictly worse
|
|
971
|
+
* than returning nothing.
|
|
972
|
+
*
|
|
973
|
+
* Narrowing runs over the rows the winning strategy already fetched, so it can
|
|
974
|
+
* miss a match beyond that page; the ladder continues to the next strategy when
|
|
975
|
+
* it comes up empty, and `recordNarrowingMiss` records how much was examined so
|
|
976
|
+
* the no-results response can disclose it rather than implying the mailbox was
|
|
977
|
+
* exhausted.
|
|
978
|
+
*
|
|
979
|
+
* The local matchers mirror their server-side counterparts but are not
|
|
980
|
+
* identical to them — `filterQueryClientSide` searches the body preview, and a
|
|
981
|
+
* display-name match is a substring test either side — so a narrowed set is a
|
|
982
|
+
* close approximation of the combined filter, not a formal equivalent.
|
|
983
|
+
*
|
|
984
|
+
* @param {Array} messages - Messages returned by the winning strategy
|
|
985
|
+
* @param {object} searchTerms - All search terms the caller supplied
|
|
986
|
+
* @param {string|null} appliedTerm - The term already satisfied server-side
|
|
987
|
+
* @returns {{matched: Array, secondaryApplied: string[]}}
|
|
988
|
+
*/
|
|
989
|
+
/**
|
|
990
|
+
* Replace a response's rows with the narrowed subset, keeping the reported
|
|
991
|
+
* totals honest. `@odata.count` was set from the pre-narrowing page, so
|
|
992
|
+
* leaving it makes `_meta.totalAvailable` claim matches that do not exist and
|
|
993
|
+
* invites a caller to paginate for them. (#229)
|
|
994
|
+
*
|
|
995
|
+
* @param {object} response - The Graph response being narrowed in place
|
|
996
|
+
* @param {Array} matched - The rows that survived narrowing
|
|
997
|
+
*/
|
|
998
|
+
function narrowResponse(response, matched) {
|
|
999
|
+
const dropped = (response.value || []).length !== matched.length;
|
|
1000
|
+
response.value = matched;
|
|
1001
|
+
if (dropped) {
|
|
1002
|
+
delete response['@odata.count'];
|
|
1003
|
+
delete response['@odata.nextLink'];
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Note that a narrowing pass examined rows and matched none of them, so the
|
|
1009
|
+
* eventual no-results response can say how much was actually looked at rather
|
|
1010
|
+
* than implying the mailbox was exhausted. (#229)
|
|
1011
|
+
*
|
|
1012
|
+
* @param {object} scanState - Side-channel carried to the no-results rung
|
|
1013
|
+
* @param {number} examined - How many rows the narrowing pass saw
|
|
1014
|
+
*/
|
|
1015
|
+
function recordNarrowingMiss(scanState, examined) {
|
|
1016
|
+
if (!scanState) return;
|
|
1017
|
+
scanState.narrowedCandidates = Math.max(
|
|
1018
|
+
scanState.narrowedCandidates || 0,
|
|
1019
|
+
examined
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function applySecondarySearchTerms(messages, searchTerms, appliedTerm) {
|
|
1024
|
+
const secondary = SEARCH_TERM_KEYS.filter(
|
|
1025
|
+
(t) => t !== appliedTerm && searchTerms[t]
|
|
1026
|
+
);
|
|
1027
|
+
let matched = messages;
|
|
1028
|
+
for (const term of secondary) {
|
|
1029
|
+
matched = CLIENT_SIDE_TERM_MATCHERS[term](matched, searchTerms[term]);
|
|
1030
|
+
}
|
|
1031
|
+
return { matched, secondaryApplied: secondary };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
624
1034
|
/**
|
|
625
1035
|
* Fetch a window of recent messages for the client-side filtering fallback.
|
|
626
1036
|
* Uses the 'search' field preset (includes toRecipients and bodyPreview).
|
|
@@ -734,7 +1144,14 @@ async function runClientSideFallback(
|
|
|
734
1144
|
kind === 'to'
|
|
735
1145
|
? filterToClientSide(messages, searchTerms.to)
|
|
736
1146
|
: filterQueryClientSide(messages, searchTerms.query);
|
|
737
|
-
|
|
1147
|
+
// The local matcher covers only `kind`; narrow by the caller's other search
|
|
1148
|
+
// terms too, or this path returns the same superset step 2 used to. (#229)
|
|
1149
|
+
const { matched: narrowed, secondaryApplied } = applySecondarySearchTerms(
|
|
1150
|
+
termMatched,
|
|
1151
|
+
searchTerms,
|
|
1152
|
+
kind
|
|
1153
|
+
);
|
|
1154
|
+
const matched = applyBooleanDateFilters(narrowed, filterTerms);
|
|
738
1155
|
if (matched.length === 0) {
|
|
739
1156
|
return null;
|
|
740
1157
|
}
|
|
@@ -748,6 +1165,8 @@ async function runClientSideFallback(
|
|
|
748
1165
|
strategies: searchAttempts,
|
|
749
1166
|
originalTerms: searchTerms,
|
|
750
1167
|
filterTerms,
|
|
1168
|
+
appliedTerms: [kind, ...secondaryApplied],
|
|
1169
|
+
...(secondaryApplied.length > 0 && { clientSideTerms: secondaryApplied }),
|
|
751
1170
|
candidatesScanned,
|
|
752
1171
|
scanLimit: CLIENT_SCAN_LIMIT,
|
|
753
1172
|
truncated,
|
|
@@ -785,7 +1204,7 @@ function buildSearchParams(searchTerms, filterTerms, count, selectFields) {
|
|
|
785
1204
|
// Use $filter for subject — $search silently fails on personal MS accounts
|
|
786
1205
|
if (searchTerms.subject) {
|
|
787
1206
|
filterConditions.push(
|
|
788
|
-
`contains(subject, '${searchTerms.subject
|
|
1207
|
+
`contains(subject, '${escapeODataString(searchTerms.subject)}')`
|
|
789
1208
|
);
|
|
790
1209
|
}
|
|
791
1210
|
|
|
@@ -887,14 +1306,103 @@ function addBooleanFilters(params, filterTerms) {
|
|
|
887
1306
|
}
|
|
888
1307
|
}
|
|
889
1308
|
|
|
1309
|
+
/**
|
|
1310
|
+
* Build no-results guidance from what the caller actually supplied and what the
|
|
1311
|
+
* progressive-search ladder actually attempted, rather than printing the same
|
|
1312
|
+
* four lines on every empty search. (#231)
|
|
1313
|
+
*
|
|
1314
|
+
* The previous fixed list included "use `from` filter instead of `to` (more
|
|
1315
|
+
* reliable on personal accounts)", which has been untrue since v3.7.1 added the
|
|
1316
|
+
* client-side `to` fallback (#139 / PR #141). The tool was teaching its callers
|
|
1317
|
+
* something false about itself.
|
|
1318
|
+
*
|
|
1319
|
+
* @param {object} searchInfo - The _searchInfo block from progressiveSearch
|
|
1320
|
+
* @param {boolean} searchAllFolders - Whether the search already spanned all folders
|
|
1321
|
+
* @returns {string[]} - Ordered suggestion lines, without the leading bullet
|
|
1322
|
+
*/
|
|
1323
|
+
function buildNoResultsSuggestions(searchInfo, searchAllFolders) {
|
|
1324
|
+
const filters = searchInfo.originalTerms || {};
|
|
1325
|
+
const strategies = searchInfo.strategies || [];
|
|
1326
|
+
const suggestions = [];
|
|
1327
|
+
|
|
1328
|
+
// Scope — only worth suggesting when it isn't already what we just did.
|
|
1329
|
+
if (searchAllFolders) {
|
|
1330
|
+
suggestions.push(
|
|
1331
|
+
'All folders were already searched, so no message in this mailbox matches these filters'
|
|
1332
|
+
);
|
|
1333
|
+
} else {
|
|
1334
|
+
suggestions.push(
|
|
1335
|
+
'Try `searchAllFolders: true` to search across all folders including Archive'
|
|
1336
|
+
);
|
|
1337
|
+
suggestions.push(
|
|
1338
|
+
'Specify the correct folder if emails have been moved (use the `folders` tool to list folders)'
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// Report the fallback the ladder actually took, instead of guessing at one.
|
|
1343
|
+
const clientSide = strategies.filter((s) => s.startsWith('client-side-'));
|
|
1344
|
+
if (clientSide.length > 0) {
|
|
1345
|
+
const fields = clientSide
|
|
1346
|
+
.map((s) => `\`${s.slice('client-side-'.length)}\``)
|
|
1347
|
+
.join(', ');
|
|
1348
|
+
let note = `${fields} was matched locally after the server-side filter came back empty`;
|
|
1349
|
+
if (searchInfo.candidatesScanned) {
|
|
1350
|
+
note += ` — ${searchInfo.candidatesScanned} recent messages examined`;
|
|
1351
|
+
if (searchInfo.truncated) {
|
|
1352
|
+
note += `, hitting the ${searchInfo.scanLimit} scan limit, so older matches were not seen (narrow with \`receivedAfter\`)`;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
suggestions.push(note);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// A narrowing pass that matched nothing saw only the page the winning
|
|
1359
|
+
// server-side filter returned — a much weaker basis for "none exists" than
|
|
1360
|
+
// the bounded client-side scan, so say so rather than implying otherwise.
|
|
1361
|
+
if (searchInfo.narrowedCandidates) {
|
|
1362
|
+
suggestions.push(
|
|
1363
|
+
`The other filters were applied locally to the ${searchInfo.narrowedCandidates} message${searchInfo.narrowedCandidates === 1 ? '' : 's'} the server-side filter returned, so a match beyond that page would not have been seen — raise \`count\`, or narrow with \`receivedAfter\``
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
if (filters.kqlQuery) {
|
|
1368
|
+
suggestions.push(
|
|
1369
|
+
'Structured filters (`from`, `to`, `subject`) reach messages that `searchExpression` cannot on personal accounts'
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (filters.query) {
|
|
1374
|
+
suggestions.push(
|
|
1375
|
+
'Free-text `query` falls back to a subject match on personal accounts — try `subject` directly, or fewer words'
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
if (filters.subject) {
|
|
1380
|
+
suggestions.push(
|
|
1381
|
+
'`subject` is a substring match — try a shorter, more distinctive fragment'
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const applied = ['from', 'to', 'subject', 'query', 'kqlQuery'].filter(
|
|
1386
|
+
(k) => filters[k]
|
|
1387
|
+
);
|
|
1388
|
+
if (applied.length > 1) {
|
|
1389
|
+
suggestions.push(
|
|
1390
|
+
`All ${applied.length} filters must match the same message — try removing one`
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
return suggestions;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
890
1397
|
/**
|
|
891
1398
|
* Format search results into Markdown using response-formatter utilities
|
|
892
1399
|
* @param {object} response - The API response object
|
|
893
1400
|
* @param {string} folder - Folder that was searched
|
|
894
1401
|
* @param {string} verbosity - Output verbosity level
|
|
1402
|
+
* @param {boolean} [searchAllFolders] - Whether the search spanned all folders
|
|
895
1403
|
* @returns {object} - MCP response object
|
|
896
1404
|
*/
|
|
897
|
-
function formatSearchResults(response, folder, verbosity) {
|
|
1405
|
+
function formatSearchResults(response, folder, verbosity, searchAllFolders) {
|
|
898
1406
|
// Build metadata
|
|
899
1407
|
const meta = {
|
|
900
1408
|
returned: (response.value || []).length,
|
|
@@ -909,11 +1417,35 @@ function formatSearchResults(response, folder, verbosity) {
|
|
|
909
1417
|
response._searchInfo.strategies[
|
|
910
1418
|
response._searchInfo.strategies.length - 1
|
|
911
1419
|
];
|
|
1420
|
+
// Which supplied filters actually reached the result set, and which the
|
|
1421
|
+
// winning strategy could not honour. `droppedFilters` is normally empty;
|
|
1422
|
+
// a non-empty value means the response is a superset of what was asked
|
|
1423
|
+
// for, and `filterApplied` must not claim otherwise. (#229)
|
|
1424
|
+
const suppliedTerms = SEARCH_FILTER_KEYS.filter(
|
|
1425
|
+
(k) => response._searchInfo.originalTerms?.[k]
|
|
1426
|
+
);
|
|
1427
|
+
const appliedTerms = response._searchInfo.appliedTerms ?? suppliedTerms;
|
|
1428
|
+
const droppedFilters = suppliedTerms
|
|
1429
|
+
.filter((k) => !appliedTerms.includes(k))
|
|
1430
|
+
.map((k) => FILTER_LABELS[k] || k);
|
|
1431
|
+
|
|
912
1432
|
meta.searchMetadata = {
|
|
913
1433
|
strategiesAttempted: response._searchInfo.strategies,
|
|
914
1434
|
finalStrategy: finalStrategy,
|
|
915
|
-
filterApplied:
|
|
1435
|
+
filterApplied:
|
|
1436
|
+
!response._searchInfo.noResults && droppedFilters.length === 0,
|
|
1437
|
+
droppedFilters,
|
|
1438
|
+
...(response._searchInfo.clientSideTerms && {
|
|
1439
|
+
clientSideFilters: response._searchInfo.clientSideTerms,
|
|
1440
|
+
}),
|
|
916
1441
|
originalFilters: response._searchInfo.originalTerms,
|
|
1442
|
+
// A field-scoped `searchExpression` that personal accounts reject is
|
|
1443
|
+
// retried as OData filters, which is a rewrite of what the caller asked
|
|
1444
|
+
// for. Report the rewrite, so `raw-kql-translated` is inspectable rather
|
|
1445
|
+
// than something the caller has to take on trust. (#217)
|
|
1446
|
+
...(response._searchInfo.kqlTranslatedTo && {
|
|
1447
|
+
kqlTranslatedTo: response._searchInfo.kqlTranslatedTo,
|
|
1448
|
+
}),
|
|
917
1449
|
// Surface client-side scan coverage so callers can tell when a fallback
|
|
918
1450
|
// result may be incomplete (older matches beyond the scan budget). (#169)
|
|
919
1451
|
...(response._searchInfo.candidatesScanned !== undefined && {
|
|
@@ -929,8 +1461,6 @@ function formatSearchResults(response, folder, verbosity) {
|
|
|
929
1461
|
// Actionable guidance when filters were specified but matched nothing
|
|
930
1462
|
if (response._searchInfo?.noResults) {
|
|
931
1463
|
const filters = response._searchInfo.originalTerms || {};
|
|
932
|
-
// Relabel internal keys to the caller-facing param names. (#169)
|
|
933
|
-
const FILTER_LABELS = { kqlQuery: 'searchExpression' };
|
|
934
1464
|
const activeFilters = Object.entries(filters)
|
|
935
1465
|
.filter(([, v]) => v)
|
|
936
1466
|
.map(([k]) => FILTER_LABELS[k] || k);
|
|
@@ -939,13 +1469,13 @@ function formatSearchResults(response, folder, verbosity) {
|
|
|
939
1469
|
? ` (filters: ${activeFilters.join(', ')})`
|
|
940
1470
|
: '';
|
|
941
1471
|
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
1472
|
+
const suggestions = buildNoResultsSuggestions(
|
|
1473
|
+
response._searchInfo,
|
|
1474
|
+
searchAllFolders
|
|
1475
|
+
);
|
|
1476
|
+
|
|
1477
|
+
const bullets = suggestions.map((line) => `- ${line}`).join('\n');
|
|
1478
|
+
const text = `No emails found matching your filters in "${folder}"${filterDesc}.\n\n**Suggestions:**\n${bullets}`;
|
|
949
1479
|
|
|
950
1480
|
return {
|
|
951
1481
|
content: [{ type: 'text', text }],
|
|
@@ -1029,7 +1559,7 @@ async function handleSearchByMessageId(args) {
|
|
|
1029
1559
|
|
|
1030
1560
|
// Build filter - need to escape the Message-ID properly
|
|
1031
1561
|
// Graph API expects: internetMessageId eq '<value>'
|
|
1032
|
-
const escapedMessageId = messageId
|
|
1562
|
+
const escapedMessageId = escapeODataString(messageId);
|
|
1033
1563
|
|
|
1034
1564
|
const params = {
|
|
1035
1565
|
$filter: `internetMessageId eq '${escapedMessageId}'`,
|
|
@@ -1112,4 +1642,6 @@ module.exports = {
|
|
|
1112
1642
|
classifyEmailFilter,
|
|
1113
1643
|
filterToClientSide,
|
|
1114
1644
|
filterQueryClientSide,
|
|
1645
|
+
filterFromClientSide,
|
|
1646
|
+
filterSubjectClientSide,
|
|
1115
1647
|
};
|
package/index.js
CHANGED
|
@@ -5,6 +5,65 @@
|
|
|
5
5
|
* A Model Context Protocol server that provides access to
|
|
6
6
|
* Microsoft Outlook through the Microsoft Graph API.
|
|
7
7
|
*/
|
|
8
|
+
// CLI flag handling (#68).
|
|
9
|
+
//
|
|
10
|
+
// Deliberately the first thing that runs: it must complete before the SDK
|
|
11
|
+
// imports, before the auth modules load, and before the startup banner below
|
|
12
|
+
// writes to stderr — otherwise `--version` output is buried in server noise and
|
|
13
|
+
// the process never exits (the SIGTERM handler below keeps it alive).
|
|
14
|
+
//
|
|
15
|
+
// `config.js` is required lazily here so this costs nothing on the normal
|
|
16
|
+
// server path; it is the single source of truth for the version, which it
|
|
17
|
+
// reads from package.json.
|
|
18
|
+
const cliArgs = process.argv.slice(2);
|
|
19
|
+
if (cliArgs.length > 0) {
|
|
20
|
+
const HELP_TEXT = `outlook-assistant — MCP server for Microsoft Outlook via the Microsoft Graph API.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
outlook-assistant [options]
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
-v, --version Print the version and exit
|
|
27
|
+
-h, --help Show this help and exit
|
|
28
|
+
|
|
29
|
+
With no options the server starts and speaks the Model Context Protocol over
|
|
30
|
+
stdio. It is normally launched by an MCP client (Claude Desktop, Claude Code)
|
|
31
|
+
rather than run by hand — started from a terminal it will simply wait on stdin.
|
|
32
|
+
|
|
33
|
+
Key environment variables:
|
|
34
|
+
OUTLOOK_CLIENT_ID Azure app registration client ID
|
|
35
|
+
OUTLOOK_CLIENT_SECRET Client secret VALUE (not the Secret ID)
|
|
36
|
+
OUTLOOK_AUTH_METHOD device-code (default) | browser
|
|
37
|
+
OUTLOOK_AUTH_AUDIENCE common | consumers | organizations | <tenant-guid>
|
|
38
|
+
OUTLOOK_MAX_EMAILS_PER_SESSION Cap on sends per session
|
|
39
|
+
OUTLOOK_ALLOWED_RECIPIENTS Comma-separated recipient allowlist
|
|
40
|
+
USE_TEST_MODE Set to "true" to run against mock data
|
|
41
|
+
|
|
42
|
+
Documentation: https://github.com/littlebearapps/outlook-assistant`;
|
|
43
|
+
|
|
44
|
+
const KNOWN_FLAGS = new Set(['--version', '-v', '--help', '-h']);
|
|
45
|
+
|
|
46
|
+
// Validate every argument before acting on any of them. Checking for a
|
|
47
|
+
// recognised flag first would let `--version --nope` succeed and silently
|
|
48
|
+
// swallow the typo — an unrecognised argument is a user error regardless of
|
|
49
|
+
// what else is on the command line.
|
|
50
|
+
const unknown = cliArgs.find((arg) => !KNOWN_FLAGS.has(arg));
|
|
51
|
+
if (unknown) {
|
|
52
|
+
console.error(
|
|
53
|
+
`outlook-assistant: unrecognised argument '${unknown}'\nRun 'outlook-assistant --help' for usage.`
|
|
54
|
+
);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (cliArgs.includes('--version') || cliArgs.includes('-v')) {
|
|
59
|
+
console.log(require('./config').SERVER_VERSION);
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(HELP_TEXT);
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
8
67
|
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
9
68
|
const {
|
|
10
69
|
StdioServerTransport,
|
package/llms-install.md
CHANGED
|
@@ -83,7 +83,7 @@ After authentication, test with:
|
|
|
83
83
|
|
|
84
84
|
| Problem | Solution |
|
|
85
85
|
|---------|----------|
|
|
86
|
-
| "Invalid client secret" (AADSTS7000215) | Use the secret **Value**, not the Secret ID |
|
|
86
|
+
| "Invalid client secret" (AADSTS7000215) | Use the secret **Value**, not the Secret ID. Also check it hasn't expired. v3.11.0+ appends an explanation to Microsoft's raw error |
|
|
87
87
|
| Auth URL doesn't work | Start the auth server first |
|
|
88
88
|
| "EADDRINUSE :3333" | Run `npx kill-port 3333` then restart auth server |
|
|
89
89
|
| Empty API responses | Run `auth` tool with `action=status` to check token |
|
package/llms.txt
CHANGED
|
@@ -22,7 +22,7 @@ Built by [Little Bear Apps](https://littlebearapps.com).
|
|
|
22
22
|
|
|
23
23
|
## Key Differentiators
|
|
24
24
|
|
|
25
|
-
- **Progressive search**: Automatically falls back through 4 search strategies when Microsoft's `$search` API is unavailable (personal accounts) — most Graph API wrappers fail silently. Explicit "no results" messaging instead of unfiltered fallback. Cross-folder search (`searchAllFolders`) reliably returns a superset of inbox results.
|
|
25
|
+
- **Progressive search**: Automatically falls back through 4 search strategies when Microsoft's `$search` API is unavailable (personal accounts) — most Graph API wrappers fail silently. Explicit "no results" messaging instead of unfiltered fallback, and `_meta.searchMetadata` reports which strategy answered plus any filter that could not be honoured (`droppedFilters`), so a partially-applied search can never pass as a complete one. Field-scoped `$search` expressions (`from:`/`to:`/`subject:`), which personal accounts reject outright, are translated to equivalent OData filters and retried. Cross-folder search (`searchAllFolders`) reliably returns a superset of inbox results.
|
|
26
26
|
- **Remote-friendly auth**: Device code flow (default) — no auth server, no port forwarding, no SSH tunnels. State persists across MCP server restarts. Works from Untether, mosh, SSH, and headless environments.
|
|
27
27
|
- **Email forensics**: Built-in header analysis for DKIM, SPF, DMARC authentication, delivery chains, and spam scores — useful for phishing investigation and compliance
|
|
28
28
|
- **Delta sync**: Incremental inbox monitoring — returns only new, modified, and deleted emails since last check, with tokens for continuous polling
|
|
@@ -79,6 +79,6 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
|
|
|
79
79
|
- [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/>)
|
|
80
80
|
- [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
|
|
81
81
|
- [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
|
|
82
|
-
- [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.
|
|
83
|
-
- [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.
|
|
82
|
+
- [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.11.0 — fixes & polish: `--version`/`--help` CLI flags, where previously any argument was ignored and the server booted and hung on stdin (#68); `AADSTS7000215` (the Secret ID vs Secret Value mistake) now explains itself instead of passing Microsoft's raw error through, via one shared AADSTS hint table (#69); token-refresh round trip covered end to end from disk to the `Authorization` header on the next Graph call (#72); all 17 development-dependency advisories cleared, so `npm audit` reports zero at every severity. Preceded by v3.10.0 — search correctness: field-scoped `searchExpression` translated to OData filters and retried on personal accounts (#217), two-filter searches no longer returning the single-filter superset with `searchMetadata.droppedFilters` reporting anything unhonoured (#229), `from`/`to` filter values OData-escaped (#230), no-results guidance derived from what was actually attempted (#231) — and v3.9.1, a packaging hotfix restoring `request-handler.js` in the published tarball (#223))
|
|
83
|
+
- [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.11.1 tool description audit, v3.8.x carry-over, v3.12.0+ new Graph APIs)
|
|
84
84
|
- [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy, 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.
|
|
3
|
+
"version": "3.11.0",
|
|
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",
|
|
@@ -98,8 +98,11 @@
|
|
|
98
98
|
},
|
|
99
99
|
"overrides": {
|
|
100
100
|
"minimatch": ">=3.1.3",
|
|
101
|
-
"hono": "^4.
|
|
102
|
-
"
|
|
101
|
+
"hono": "^4.13.7",
|
|
102
|
+
"@hono/node-server": "^1.19.17",
|
|
103
|
+
"fast-uri": "^3.1.7",
|
|
104
|
+
"ip-address": "^10.7.0",
|
|
105
|
+
"qs": "^6.16.0",
|
|
103
106
|
"body-parser": "^2.3.0"
|
|
104
107
|
}
|
|
105
108
|
}
|