@littlebearapps/outlook-assistant 3.12.0 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/README.md +58 -18
- package/advanced/index.js +80 -36
- package/auth/client-config.js +142 -0
- package/auth/index.js +4 -2
- package/auth/oauth-server.js +5 -1
- package/auth/token-storage.js +29 -14
- package/auth/tools.js +179 -18
- package/calendar/attendees.js +101 -0
- package/calendar/cancel.js +5 -4
- package/calendar/create.js +15 -4
- package/calendar/decline.js +10 -5
- package/calendar/index.js +33 -10
- package/calendar/list.js +10 -19
- package/calendar/update.js +65 -33
- package/config.js +37 -1
- package/contacts/index.js +2 -1
- package/email/attachments.js +7 -35
- package/email/conversations.js +155 -88
- package/email/delta.js +29 -9
- package/email/draft.js +66 -9
- package/email/export.js +5 -79
- package/email/folder-utils.js +0 -123
- package/email/index.js +12 -10
- package/email/search.js +9 -4
- package/folder/index.js +1 -1
- package/folder/resolve.js +3 -2
- package/index.js +13 -3
- package/llms-install.md +10 -4
- package/llms.txt +7 -7
- package/package.json +3 -2
- package/rules/index.js +3 -3
- package/rules/rule-builder.js +61 -16
- package/utils/datetime.js +170 -0
- package/utils/graph-api.js +324 -218
- package/utils/mock-data.js +3 -0
- package/utils/odata-helpers.js +24 -0
- package/utils/safe-write.js +151 -0
- package/calendar/accept.js +0 -72
package/.env.example
CHANGED
|
@@ -31,6 +31,14 @@ USE_TEST_MODE=false
|
|
|
31
31
|
# receivedBefore rather than raising this if you can.
|
|
32
32
|
# OUTLOOK_SEARCH_SCAN_LIMIT=500
|
|
33
33
|
|
|
34
|
+
# Inactivity timeout for each Graph request attempt (milliseconds): an attempt
|
|
35
|
+
# that receives no data for this long is abandoned with a timeout error. It is
|
|
36
|
+
# not an overall deadline — a slow response that keeps arriving isn't cut off.
|
|
37
|
+
# Throttled (429) and busy (503/504) responses are retried automatically (up to
|
|
38
|
+
# 3 times, honouring Retry-After); POST requests such as sending mail are only
|
|
39
|
+
# retried on a 429 asking for a short wait (10 s or less, 20 s in total).
|
|
40
|
+
# OUTLOOK_REQUEST_TIMEOUT_MS=60000
|
|
41
|
+
|
|
34
42
|
# Optional: Default authentication method (device-code or browser)
|
|
35
43
|
# device-code: No auth server needed, works remotely/headless
|
|
36
44
|
# browser: Traditional OAuth redirect via localhost:3333
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<a href="https://glama.ai/mcp/servers/littlebearapps/outlook-assistant"><img src="https://glama.ai/mcp/servers/littlebearapps/outlook-assistant/badges/score.svg" alt="Glama score" /></a>
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
|
-
Outlook Assistant connects AI assistants to your Microsoft Outlook account through the [Model Context Protocol](https://modelcontextprotocol.io/). Ask your AI assistant to search your inbox, send emails, schedule meetings, manage contacts, and configure mailbox settings — without leaving the conversation. Works with Claude, Cursor, Windsurf, and any MCP-compatible client.
|
|
20
|
+
Outlook Assistant connects AI assistants to your Microsoft Outlook account through the [Model Context Protocol](https://modelcontextprotocol.io/). Ask your AI assistant to search your inbox, send emails, schedule meetings, manage contacts, and configure mailbox settings — without leaving the conversation. Works with Claude, GitHub Copilot, Cursor, Windsurf, and any MCP-compatible client.
|
|
21
21
|
|
|
22
22
|
**Works with personal Outlook.com and work/school Microsoft 365 accounts.**
|
|
23
23
|
|
|
@@ -135,7 +135,6 @@ Outlook Assistant is designed with safety-first principles for AI-driven email a
|
|
|
135
135
|
> ```json
|
|
136
136
|
> "env": {
|
|
137
137
|
> "OUTLOOK_CLIENT_ID": "…",
|
|
138
|
-
> "OUTLOOK_CLIENT_SECRET": "…",
|
|
139
138
|
> "OUTLOOK_MAX_EMAILS_PER_SESSION": "10",
|
|
140
139
|
> "OUTLOOK_ALLOWED_RECIPIENTS": "your-domain.com,trusted@example.com"
|
|
141
140
|
> }
|
|
@@ -143,7 +142,7 @@ Outlook Assistant is designed with safety-first principles for AI-driven email a
|
|
|
143
142
|
|
|
144
143
|
**Input and file hardening** — IDs containing `.` or `..` path segments are refused before any request is made, continuation links (`deltaToken`) must point at `graph.microsoft.com`, and attachment downloads and exports write sanitised filenames inside the output directory without overwriting existing files or following symlinks.
|
|
145
144
|
|
|
146
|
-
**Draft protections** — The `draft` tool shares `send-email` safety controls: dry-run preview, recipient allowlist, mail-tips validation, and rate limiting. The `send` action shares the `send-email` rate limit counter, preventing circumvention via the draft-then-send pathway.
|
|
145
|
+
**Draft protections** — The `draft` tool shares `send-email` safety controls: dry-run preview, recipient allowlist, mail-tips validation, and rate limiting. The `send` action shares the `send-email` rate limit counter, preventing circumvention via the draft-then-send pathway. `update`, `send` and `delete` refuse any ID that is not an unsent draft, so a received or sent message is never edited, deleted or re-sent.
|
|
147
146
|
|
|
148
147
|
**Token-optimised architecture** — Tools are consolidated using the STRAP (Single Tool, Resource, Action Pattern) approach. 22 tools instead of 55 reduces per-turn overhead by ~11,000 tokens (~64%), keeping more of the AI's context window available for your actual conversation. Fewer tools also means the AI selects the right tool more accurately — research shows tool selection degrades beyond ~40 tools.
|
|
149
148
|
|
|
@@ -166,7 +165,7 @@ npx @littlebearapps/outlook-assistant
|
|
|
166
165
|
To check which version you have, or to see the available options:
|
|
167
166
|
|
|
168
167
|
```bash
|
|
169
|
-
outlook-assistant --version # prints e.g. 3.
|
|
168
|
+
outlook-assistant --version # prints e.g. 3.13.0
|
|
170
169
|
outlook-assistant --help # usage, options and key environment variables
|
|
171
170
|
```
|
|
172
171
|
|
|
@@ -180,14 +179,23 @@ You need a Microsoft Azure app registration to authenticate. See the **[Azure Se
|
|
|
180
179
|
|
|
181
180
|
1. Create a new app registration at [portal.azure.com](https://portal.azure.com/)
|
|
182
181
|
2. Add Microsoft Graph delegated permissions (Mail, Calendar, Contacts)
|
|
183
|
-
3. Create a client secret and copy the **Value** (not the Secret ID)
|
|
182
|
+
3. _(Browser flow only)_ Create a client secret and copy the **Value** (not the Secret ID). The default device-code sign-in doesn't need one
|
|
184
183
|
4. Under Authentication > **Add a platform** > **Mobile and desktop applications** — check `nativeclient` URI
|
|
185
184
|
5. Enable **"Allow public client flows"** in Authentication > Advanced settings
|
|
186
185
|
6. _(Optional)_ Set redirect URI to `http://localhost:3333/auth/callback` — only needed for browser auth flow
|
|
187
186
|
|
|
188
187
|
### 3. Configure Your MCP Client
|
|
189
188
|
|
|
190
|
-
|
|
189
|
+
**Plugin install (Claude Code).** The plugin bundles the server pinned to an exact version and asks for your settings when you enable it:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
claude plugin marketplace add littlebearapps/outlook-assistant
|
|
193
|
+
claude plugin install outlook-assistant@littlebearapps
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The same plugin folder ([`plugins/outlook-assistant`](plugins/outlook-assistant/)) also follows the [Agent Plugins](https://agent-plugins.org/) format used by GitHub Copilot and Cursor.
|
|
197
|
+
|
|
198
|
+
**Manual config.** Add to your MCP client config. Only `OUTLOOK_CLIENT_ID` is needed for the default device-code sign-in; add `OUTLOOK_CLIENT_SECRET` only if you use the [browser flow](#browser-redirect-flow-alternative). You can also leave the client ID out and give it to your assistant when you first connect (`auth action=authenticate clientId=…`), which saves it to `~/.outlook-assistant-config.json`. An `OUTLOOK_CLIENT_ID` in the environment always takes precedence.
|
|
191
199
|
|
|
192
200
|
<details>
|
|
193
201
|
<summary><strong>Claude Desktop</strong> (<code>claude_desktop_config.json</code>)</summary>
|
|
@@ -199,8 +207,7 @@ Add to your MCP client config:
|
|
|
199
207
|
"command": "npx",
|
|
200
208
|
"args": ["@littlebearapps/outlook-assistant"],
|
|
201
209
|
"env": {
|
|
202
|
-
"OUTLOOK_CLIENT_ID": "your-application-client-id"
|
|
203
|
-
"OUTLOOK_CLIENT_SECRET": "your-client-secret-VALUE"
|
|
210
|
+
"OUTLOOK_CLIENT_ID": "your-application-client-id"
|
|
204
211
|
}
|
|
205
212
|
}
|
|
206
213
|
}
|
|
@@ -214,17 +221,46 @@ Add to your MCP client config:
|
|
|
214
221
|
```bash
|
|
215
222
|
claude mcp add outlook \
|
|
216
223
|
-e OUTLOOK_CLIENT_ID=your-application-client-id \
|
|
217
|
-
-e OUTLOOK_CLIENT_SECRET=your-client-secret-VALUE \
|
|
218
224
|
-- npx -y @littlebearapps/outlook-assistant
|
|
219
225
|
```
|
|
220
226
|
|
|
221
227
|
The MCP server reads its settings from the environment your client passes it; it doesn't load a `.env` file.
|
|
222
228
|
</details>
|
|
223
229
|
|
|
230
|
+
<details>
|
|
231
|
+
<summary><strong>VS Code / GitHub Copilot</strong> (<code>.vscode/mcp.json</code>)</summary>
|
|
232
|
+
|
|
233
|
+
VS Code prompts for the client ID the first time the server starts and stores it securely:
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"inputs": [
|
|
238
|
+
{
|
|
239
|
+
"type": "promptString",
|
|
240
|
+
"id": "outlook-client-id",
|
|
241
|
+
"description": "Azure application (client) ID"
|
|
242
|
+
}
|
|
243
|
+
],
|
|
244
|
+
"servers": {
|
|
245
|
+
"outlook": {
|
|
246
|
+
"type": "stdio",
|
|
247
|
+
"command": "npx",
|
|
248
|
+
"args": ["-y", "@littlebearapps/outlook-assistant"],
|
|
249
|
+
"env": {
|
|
250
|
+
"OUTLOOK_CLIENT_ID": "${input:outlook-client-id}"
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Use it from Copilot Chat in **Agent** mode. To use it in every workspace, add the same entry to your user `mcp.json` (Command Palette → **MCP: Open User Configuration**).
|
|
258
|
+
</details>
|
|
259
|
+
|
|
224
260
|
<details>
|
|
225
261
|
<summary><strong>Cursor</strong> (<code>.cursor/mcp.json</code>)</summary>
|
|
226
262
|
|
|
227
|
-
[](cursor://anysphere.cursor-deeplink/mcp/install?name=Outlook%20Assistant&config=
|
|
263
|
+
[](cursor://anysphere.cursor-deeplink/mcp/install?name=Outlook%20Assistant&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIkBsaXR0bGViZWFyYXBwcy9vdXRsb29rLWFzc2lzdGFudCJdLCJlbnYiOnsiT1VUTE9PS19DTElFTlRfSUQiOiIifX0=)
|
|
228
264
|
|
|
229
265
|
Or add manually to `.cursor/mcp.json`:
|
|
230
266
|
|
|
@@ -235,8 +271,7 @@ Or add manually to `.cursor/mcp.json`:
|
|
|
235
271
|
"command": "npx",
|
|
236
272
|
"args": ["@littlebearapps/outlook-assistant"],
|
|
237
273
|
"env": {
|
|
238
|
-
"OUTLOOK_CLIENT_ID": "your-application-client-id"
|
|
239
|
-
"OUTLOOK_CLIENT_SECRET": "your-client-secret-VALUE"
|
|
274
|
+
"OUTLOOK_CLIENT_ID": "your-application-client-id"
|
|
240
275
|
}
|
|
241
276
|
}
|
|
242
277
|
}
|
|
@@ -254,8 +289,7 @@ Or add manually to `.cursor/mcp.json`:
|
|
|
254
289
|
"command": "npx",
|
|
255
290
|
"args": ["@littlebearapps/outlook-assistant"],
|
|
256
291
|
"env": {
|
|
257
|
-
"OUTLOOK_CLIENT_ID": "your-application-client-id"
|
|
258
|
-
"OUTLOOK_CLIENT_SECRET": "your-client-secret-VALUE"
|
|
292
|
+
"OUTLOOK_CLIENT_ID": "your-application-client-id"
|
|
259
293
|
}
|
|
260
294
|
}
|
|
261
295
|
}
|
|
@@ -339,6 +373,8 @@ a server that would ignore it.
|
|
|
339
373
|
|
|
340
374
|
### Create a Client Secret
|
|
341
375
|
|
|
376
|
+
Only needed for the [browser redirect flow](#browser-redirect-flow-alternative). Skip this if you sign in with the default device code.
|
|
377
|
+
|
|
342
378
|
1. Go to **Certificates & secrets** > **New client secret**
|
|
343
379
|
2. Enter a description and select expiration
|
|
344
380
|
3. Click **Add**
|
|
@@ -374,10 +410,11 @@ USE_TEST_MODE=false
|
|
|
374
410
|
| `OUTLOOK_ALLOWED_RECIPIENTS` | Comma-separated allowlist of domains/addresses for sends, drafts, and rule forwards. | unrestricted |
|
|
375
411
|
| `OUTLOOK_SHARED_MAILBOX` | Opt-in shared-mailbox support (work/school only). `read` requests `Mail.Read.Shared`; `true` (or `readwrite`/`1`) also requests `Mail.ReadWrite.Shared`. Unset leaves sign-in unchanged. After enabling, restart and run `auth action=authenticate force=true`. | unset (off) |
|
|
376
412
|
| `OUTLOOK_SEARCH_SCAN_LIMIT` | How many recent messages the client-side search fallback scans. Personal accounts match `to` locally within this window, so the default caps how far back a `to` search reaches. Max 5000. | `500` |
|
|
413
|
+
| `OUTLOOK_REQUEST_TIMEOUT_MS` | Inactivity timeout for each Graph request attempt, in milliseconds: an attempt that receives no data for this long is abandoned with a timeout error. It isn't an overall deadline, so a slow response that keeps arriving isn't cut off. Throttled (`429`) and busy (`503`/`504`) responses are retried automatically, honouring `Retry-After`. | `60000` |
|
|
377
414
|
|
|
378
415
|
### MCP Client Configuration
|
|
379
416
|
|
|
380
|
-
See [Quick Start — Configure Your MCP Client](#3-configure-your-mcp-client) above for Claude Desktop, Claude Code, Cursor, and Windsurf configs.
|
|
417
|
+
See [Quick Start — Configure Your MCP Client](#3-configure-your-mcp-client) above for Claude Desktop, Claude Code, VS Code / GitHub Copilot, Cursor, and Windsurf configs.
|
|
381
418
|
|
|
382
419
|
If installed from source, use `node` instead of `npx`:
|
|
383
420
|
|
|
@@ -388,8 +425,7 @@ If installed from source, use `node` instead of `npx`:
|
|
|
388
425
|
"command": "node",
|
|
389
426
|
"args": ["/path/to/outlook-assistant/index.js"],
|
|
390
427
|
"env": {
|
|
391
|
-
"OUTLOOK_CLIENT_ID": "your-application-client-id"
|
|
392
|
-
"OUTLOOK_CLIENT_SECRET": "your-client-secret-VALUE"
|
|
428
|
+
"OUTLOOK_CLIENT_ID": "your-application-client-id"
|
|
393
429
|
}
|
|
394
430
|
}
|
|
395
431
|
}
|
|
@@ -451,7 +487,9 @@ outlook-assistant/
|
|
|
451
487
|
│ ├── conversations.js # Thread listing/export
|
|
452
488
|
│ ├── attachments.js # Attachment operations
|
|
453
489
|
│ └── ...
|
|
454
|
-
├── calendar/ # Calendar module (3 tools
|
|
490
|
+
├── calendar/ # Calendar module (3 tools)
|
|
491
|
+
│ ├── attendees.js # Attendee builder (email or {email, type})
|
|
492
|
+
│ └── list.js # list-events filters
|
|
455
493
|
├── contacts/ # Contacts module (2 tools)
|
|
456
494
|
├── categories/ # Categories module (3 tools)
|
|
457
495
|
├── settings/ # Settings module (1 tool)
|
|
@@ -462,6 +500,8 @@ outlook-assistant/
|
|
|
462
500
|
├── graph-api.js # Microsoft Graph API client (includes $batch, path guards)
|
|
463
501
|
├── mailbox.js # me vs users/{sharedMailbox} prefix, shared-mailbox opt-in
|
|
464
502
|
├── safety.js # Rate limiting, recipient allowlist, dry-run
|
|
503
|
+
├── safe-write.js # Exclusive, outputDir-confined file writes
|
|
504
|
+
├── datetime.js # ISO 8601 parsing and timezone conversion
|
|
465
505
|
├── odata-helpers.js # OData query building
|
|
466
506
|
├── field-presets.js # Token-efficient field selections
|
|
467
507
|
├── response-formatter.js # Verbosity levels
|
package/advanced/index.js
CHANGED
|
@@ -20,6 +20,12 @@ const {
|
|
|
20
20
|
} = require('../utils/mailbox');
|
|
21
21
|
const { resolveFolder } = require('../folder/resolve');
|
|
22
22
|
const { getAllFoldersHierarchy } = require('../folder/list');
|
|
23
|
+
const {
|
|
24
|
+
InvalidDateTimeError,
|
|
25
|
+
toGraphDateTimeTimeZone,
|
|
26
|
+
zonedParts,
|
|
27
|
+
zonedWallTimeToUtcMs,
|
|
28
|
+
} = require('../utils/datetime');
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
31
|
* Format an email for display (simplified)
|
|
@@ -408,6 +414,49 @@ async function handleListSharedMailboxFolders(sharedMailbox, args) {
|
|
|
408
414
|
}
|
|
409
415
|
}
|
|
410
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Default follow-up start for a flag with only a due date: 09:00 in
|
|
419
|
+
* DEFAULT_TIMEZONE on the due's local date, or the due itself when the due is
|
|
420
|
+
* earlier than that (a start after the due would be nonsense).
|
|
421
|
+
*/
|
|
422
|
+
function deriveFlagStart(due) {
|
|
423
|
+
let wall;
|
|
424
|
+
if (due.timeZone === DEFAULT_TIMEZONE) {
|
|
425
|
+
wall = due.dateTime;
|
|
426
|
+
} else {
|
|
427
|
+
const parts = zonedParts(Date.parse(`${due.dateTime}Z`), DEFAULT_TIMEZONE);
|
|
428
|
+
if (!parts) return { ...due };
|
|
429
|
+
wall = `${parts.date}T${parts.time}`;
|
|
430
|
+
}
|
|
431
|
+
const nineAm = `${wall.slice(0, 10)}T09:00:00`;
|
|
432
|
+
// Same zone and fixed-width prefix, so string order is time order.
|
|
433
|
+
return wall < nineAm
|
|
434
|
+
? { ...due }
|
|
435
|
+
: { dateTime: nineAm, timeZone: DEFAULT_TIMEZONE };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Describe a flag dateTimeTimeZone envelope as UTC plus DEFAULT_TIMEZONE,
|
|
440
|
+
* e.g. "2026-03-01 09:00 UTC (2026-03-01 20:00 Australia/Melbourne)".
|
|
441
|
+
*/
|
|
442
|
+
function describeFlagTime(envelope) {
|
|
443
|
+
const ms =
|
|
444
|
+
envelope.timeZone === 'UTC'
|
|
445
|
+
? Date.parse(`${envelope.dateTime}Z`)
|
|
446
|
+
: zonedWallTimeToUtcMs(envelope.dateTime, envelope.timeZone);
|
|
447
|
+
const local = (date, time) =>
|
|
448
|
+
`${date} ${time.slice(0, 5)} ${DEFAULT_TIMEZONE}`;
|
|
449
|
+
if (Number.isNaN(ms)) {
|
|
450
|
+
// DEFAULT_TIMEZONE isn't an IANA zone Intl knows; show it as given.
|
|
451
|
+
return local(envelope.dateTime.slice(0, 10), envelope.dateTime.slice(11));
|
|
452
|
+
}
|
|
453
|
+
const iso = new Date(ms).toISOString();
|
|
454
|
+
const utc = `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
|
|
455
|
+
const parts =
|
|
456
|
+
DEFAULT_TIMEZONE === 'UTC' ? null : zonedParts(ms, DEFAULT_TIMEZONE);
|
|
457
|
+
return parts ? `${utc} (${local(parts.date, parts.time)})` : utc;
|
|
458
|
+
}
|
|
459
|
+
|
|
411
460
|
/**
|
|
412
461
|
* Set message flag handler
|
|
413
462
|
*/
|
|
@@ -435,43 +484,38 @@ async function handleSetMessageFlag(args) {
|
|
|
435
484
|
};
|
|
436
485
|
}
|
|
437
486
|
|
|
487
|
+
// Build flag object. Zoned values (Z/offset) are sent as the same instant in
|
|
488
|
+
// UTC; zone-less values are read in DEFAULT_TIMEZONE. Bad dates are refused
|
|
489
|
+
// here, before authenticating or calling Graph.
|
|
490
|
+
const flag = {
|
|
491
|
+
flagStatus: 'flagged',
|
|
492
|
+
};
|
|
438
493
|
try {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
}
|
|
445
|
-
|
|
494
|
+
if (startDateTime) {
|
|
495
|
+
flag.startDateTime = toGraphDateTimeTimeZone(
|
|
496
|
+
startDateTime,
|
|
497
|
+
'startDateTime'
|
|
498
|
+
);
|
|
499
|
+
}
|
|
446
500
|
if (dueDateTime) {
|
|
447
|
-
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
dateTime: dueDt,
|
|
452
|
-
timeZone: DEFAULT_TIMEZONE,
|
|
453
|
-
};
|
|
454
|
-
|
|
455
|
-
// Graph API requires startDateTime when dueDateTime is set
|
|
456
|
-
// Default to start of the same day if not explicitly provided
|
|
457
|
-
if (startDateTime) {
|
|
458
|
-
flag.startDateTime = {
|
|
459
|
-
dateTime: startDateTime.replace(/Z$/i, ''),
|
|
460
|
-
timeZone: DEFAULT_TIMEZONE,
|
|
461
|
-
};
|
|
462
|
-
} else {
|
|
463
|
-
const startOfDay = `${dueDt.split('T')[0]}T09:00:00`;
|
|
464
|
-
flag.startDateTime = {
|
|
465
|
-
dateTime: startOfDay,
|
|
466
|
-
timeZone: DEFAULT_TIMEZONE,
|
|
467
|
-
};
|
|
501
|
+
flag.dueDateTime = toGraphDateTimeTimeZone(dueDateTime, 'dueDateTime');
|
|
502
|
+
// Graph requires startDateTime when dueDateTime is set.
|
|
503
|
+
if (!flag.startDateTime) {
|
|
504
|
+
flag.startDateTime = deriveFlagStart(flag.dueDateTime);
|
|
468
505
|
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
506
|
+
}
|
|
507
|
+
} catch (error) {
|
|
508
|
+
if (error instanceof InvalidDateTimeError) {
|
|
509
|
+
return {
|
|
510
|
+
content: [{ type: 'text', text: error.message }],
|
|
511
|
+
isError: true,
|
|
473
512
|
};
|
|
474
513
|
}
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const accessToken = await ensureAuthenticated();
|
|
475
519
|
|
|
476
520
|
// Process all messages
|
|
477
521
|
const results = [];
|
|
@@ -493,11 +537,11 @@ async function handleSetMessageFlag(args) {
|
|
|
493
537
|
if (results.length > 0) {
|
|
494
538
|
output.push(`Flagged ${results.length} message(s) for follow-up`);
|
|
495
539
|
|
|
496
|
-
if (dueDateTime) {
|
|
497
|
-
output.push(`**Due**: ${
|
|
540
|
+
if (flag.dueDateTime) {
|
|
541
|
+
output.push(`**Due**: ${describeFlagTime(flag.dueDateTime)}`);
|
|
498
542
|
}
|
|
499
|
-
if (startDateTime) {
|
|
500
|
-
output.push(`**Start**: ${
|
|
543
|
+
if (flag.startDateTime) {
|
|
544
|
+
output.push(`**Start**: ${describeFlagTime(flag.startDateTime)}`);
|
|
501
545
|
}
|
|
502
546
|
}
|
|
503
547
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-supplied Azure Application (client) ID.
|
|
3
|
+
*
|
|
4
|
+
* Plugin marketplaces (GitHub Copilot, Cursor — Agent Plugins 1.0) ship a
|
|
5
|
+
* static `mcp.json` with no way to prompt for settings, so users there can't
|
|
6
|
+
* set OUTLOOK_CLIENT_ID. Device-code sign-in only needs the client ID (never
|
|
7
|
+
* the secret), so the `auth` tool accepts it at runtime and it's persisted to
|
|
8
|
+
* `~/.outlook-assistant-config.json`.
|
|
9
|
+
*
|
|
10
|
+
* Precedence: OUTLOOK_CLIENT_ID env → MS_CLIENT_ID env (legacy) → saved file.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately does NOT require ../config (config.js requires this module).
|
|
13
|
+
*/
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const CONFIG_FILE_NAME = '.outlook-assistant-config.json';
|
|
19
|
+
|
|
20
|
+
// Azure Application (client) IDs are GUIDs.
|
|
21
|
+
const CLIENT_ID_RE =
|
|
22
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Path of the persisted config file. Resolved per call (not at load) so a
|
|
26
|
+
* changed HOME — e.g. in tests — is honoured.
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function getConfigPath() {
|
|
30
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
31
|
+
return path.join(homeDir, CONFIG_FILE_NAME);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {unknown} id
|
|
36
|
+
* @returns {boolean} - true when `id` (trimmed) is a GUID
|
|
37
|
+
*/
|
|
38
|
+
function isValidClientId(id) {
|
|
39
|
+
return typeof id === 'string' && CLIENT_ID_RE.test(id.trim());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read the whole config file. Never throws.
|
|
44
|
+
* @returns {object} - Parsed object, or {} when missing/unreadable/not an object
|
|
45
|
+
*/
|
|
46
|
+
function readConfigFile() {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(fs.readFileSync(getConfigPath(), 'utf8'));
|
|
49
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
50
|
+
? parsed
|
|
51
|
+
: {};
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The saved client ID, or '' when there is none or it isn't a valid GUID.
|
|
59
|
+
* Never throws.
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function loadSavedClientId() {
|
|
63
|
+
const { clientId } = readConfigFile();
|
|
64
|
+
return isValidClientId(clientId) ? clientId.trim() : '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate and persist a client ID (mode 0600, temp file + rename), keeping
|
|
69
|
+
* any other keys already in the file.
|
|
70
|
+
* @param {string} id
|
|
71
|
+
* @returns {string} - The normalised (trimmed) ID that was saved
|
|
72
|
+
* @throws {Error} - When `id` isn't a GUID or the write fails
|
|
73
|
+
*/
|
|
74
|
+
function saveClientId(id) {
|
|
75
|
+
if (!isValidClientId(id)) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
'Invalid client ID: expected the Application (client) ID GUID from your Azure app registration.'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const clientId = id.trim();
|
|
81
|
+
const filePath = getConfigPath();
|
|
82
|
+
const data = { ...readConfigFile(), clientId };
|
|
83
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
84
|
+
try {
|
|
85
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, {
|
|
86
|
+
mode: 0o600,
|
|
87
|
+
flag: 'wx',
|
|
88
|
+
});
|
|
89
|
+
fs.renameSync(tmpPath, filePath);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
try {
|
|
92
|
+
fs.unlinkSync(tmpPath);
|
|
93
|
+
} catch {
|
|
94
|
+
// Temp file was never created or is already gone
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
return clientId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Which environment variable supplies the client ID, if any.
|
|
103
|
+
* @returns {{name: string, value: string}|null}
|
|
104
|
+
*/
|
|
105
|
+
function getEnvClientId() {
|
|
106
|
+
// Trimmed, and blank counts as unset: plugin managers pass "" (or stray
|
|
107
|
+
// whitespace) for an unset option, which must not hide a saved ID.
|
|
108
|
+
for (const name of ['OUTLOOK_CLIENT_ID', 'MS_CLIENT_ID']) {
|
|
109
|
+
const value = (process.env[name] || '').trim();
|
|
110
|
+
if (value) return { name, value };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Effective client ID: env (OUTLOOK_CLIENT_ID, then legacy MS_CLIENT_ID),
|
|
117
|
+
* then the saved file, then ''.
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
function resolveClientId() {
|
|
121
|
+
const env = getEnvClientId();
|
|
122
|
+
return env ? env.value : loadSavedClientId();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @returns {'env'|'saved'|'none'} - Where the effective client ID comes from
|
|
127
|
+
*/
|
|
128
|
+
function getClientIdSource() {
|
|
129
|
+
if (getEnvClientId()) return 'env';
|
|
130
|
+
return loadSavedClientId() ? 'saved' : 'none';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
CONFIG_FILE_NAME,
|
|
135
|
+
getConfigPath,
|
|
136
|
+
isValidClientId,
|
|
137
|
+
loadSavedClientId,
|
|
138
|
+
saveClientId,
|
|
139
|
+
getEnvClientId,
|
|
140
|
+
resolveClientId,
|
|
141
|
+
getClientIdSource,
|
|
142
|
+
};
|
package/auth/index.js
CHANGED
|
@@ -6,9 +6,11 @@ const TokenStorage = require('./token-storage');
|
|
|
6
6
|
const config = require('../config');
|
|
7
7
|
const { authTools, setToolCount } = require('./tools');
|
|
8
8
|
|
|
9
|
-
// Singleton TokenStorage instance with auto-refresh support
|
|
9
|
+
// Singleton TokenStorage instance with auto-refresh support. No clientId is
|
|
10
|
+
// passed: TokenStorage resolves it on each use (env → saved config file), so
|
|
11
|
+
// an ID saved at runtime via `auth action=authenticate clientId=…` applies
|
|
12
|
+
// without a restart.
|
|
10
13
|
const tokenStorage = new TokenStorage({
|
|
11
|
-
clientId: config.AUTH_CONFIG.clientId,
|
|
12
14
|
clientSecret: config.AUTH_CONFIG.clientSecret,
|
|
13
15
|
tokenStorePath: config.AUTH_CONFIG.tokenStorePath,
|
|
14
16
|
scopes: config.AUTH_CONFIG.scopes,
|
package/auth/oauth-server.js
CHANGED
|
@@ -4,6 +4,7 @@ const _https = require('https'); // Reserved for future HTTPS support
|
|
|
4
4
|
const _fs = require('fs'); // Reserved for future HTTPS support
|
|
5
5
|
const crypto = require('crypto'); // Added for generating random string
|
|
6
6
|
const TokenStorage = require('./token-storage'); // Assuming TokenStorage is in the same directory
|
|
7
|
+
const { loadSavedClientId } = require('./client-config');
|
|
7
8
|
|
|
8
9
|
// HTML templates
|
|
9
10
|
function escapeHtml(unsafe) {
|
|
@@ -54,8 +55,11 @@ const templates = {
|
|
|
54
55
|
|
|
55
56
|
function createAuthConfig(envPrefix = 'OUTLOOK_') {
|
|
56
57
|
return {
|
|
58
|
+
// Env first; then the ID saved via `auth action=authenticate clientId=…`.
|
|
57
59
|
clientId:
|
|
58
|
-
process.env[`${envPrefix}CLIENT_ID`] ||
|
|
60
|
+
process.env[`${envPrefix}CLIENT_ID`] ||
|
|
61
|
+
process.env.MS_CLIENT_ID ||
|
|
62
|
+
loadSavedClientId(),
|
|
59
63
|
clientSecret:
|
|
60
64
|
process.env[`${envPrefix}CLIENT_SECRET`] ||
|
|
61
65
|
process.env.MS_CLIENT_SECRET ||
|
package/auth/token-storage.js
CHANGED
|
@@ -4,6 +4,7 @@ const path = require('path');
|
|
|
4
4
|
const https = require('https');
|
|
5
5
|
const querystring = require('querystring');
|
|
6
6
|
const { describeAuthError } = require('./auth-errors');
|
|
7
|
+
const { resolveClientId } = require('./client-config');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Decide which scopes a refresh request should use. Prefer the scopes that were
|
|
@@ -42,7 +43,9 @@ class TokenStorage {
|
|
|
42
43
|
process.env.HOME || process.env.USERPROFILE,
|
|
43
44
|
'.outlook-assistant-tokens.json'
|
|
44
45
|
),
|
|
45
|
-
clientId:
|
|
46
|
+
// No default clientId: when none is passed, getClientId() resolves it on
|
|
47
|
+
// every use (env → saved ~/.outlook-assistant-config.json), so an ID
|
|
48
|
+
// saved at runtime via the auth tool takes effect without a restart.
|
|
46
49
|
clientSecret:
|
|
47
50
|
process.env.OUTLOOK_CLIENT_SECRET || process.env.MS_CLIENT_SECRET,
|
|
48
51
|
redirectUri:
|
|
@@ -80,7 +83,7 @@ class TokenStorage {
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
83
|
-
if (!this.
|
|
86
|
+
if (!this.getClientId()) {
|
|
84
87
|
console.warn(
|
|
85
88
|
'TokenStorage: OUTLOOK_CLIENT_ID is not configured. Token operations will fail.'
|
|
86
89
|
);
|
|
@@ -89,6 +92,15 @@ class TokenStorage {
|
|
|
89
92
|
// Device code flow (public client) does not use client_secret.
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Client ID for token requests: an explicitly configured value wins,
|
|
97
|
+
* otherwise it is resolved now (OUTLOOK_CLIENT_ID → MS_CLIENT_ID → saved).
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
getClientId() {
|
|
101
|
+
return this.config.clientId || resolveClientId();
|
|
102
|
+
}
|
|
103
|
+
|
|
92
104
|
async _loadTokensFromFile() {
|
|
93
105
|
try {
|
|
94
106
|
const tokenData = await fs.readFile(this.config.tokenStorePath, 'utf8');
|
|
@@ -149,12 +161,12 @@ class TokenStorage {
|
|
|
149
161
|
await this.getTokens(); // Ensure tokens are loaded
|
|
150
162
|
|
|
151
163
|
if (!this.tokens || !this.tokens.access_token) {
|
|
152
|
-
console.
|
|
164
|
+
console.error('No access token available.');
|
|
153
165
|
return null;
|
|
154
166
|
}
|
|
155
167
|
|
|
156
168
|
if (this.isTokenExpired()) {
|
|
157
|
-
console.
|
|
169
|
+
console.error(
|
|
158
170
|
'Access token expired or nearing expiration. Attempting refresh.'
|
|
159
171
|
);
|
|
160
172
|
if (this.tokens.refresh_token) {
|
|
@@ -193,7 +205,7 @@ class TokenStorage {
|
|
|
193
205
|
|
|
194
206
|
// Prevent multiple concurrent refresh attempts
|
|
195
207
|
if (this._refreshPromise) {
|
|
196
|
-
console.
|
|
208
|
+
console.error('Refresh already in progress, returning existing promise.');
|
|
197
209
|
return this._refreshPromise.then((tokens) => tokens.access_token);
|
|
198
210
|
}
|
|
199
211
|
|
|
@@ -201,12 +213,12 @@ class TokenStorage {
|
|
|
201
213
|
// in refresh requests for tokens obtained via device code.
|
|
202
214
|
// Browser flow (confidential client) requires client_secret.
|
|
203
215
|
const isDeviceCode = this.tokens.auth_method === 'device-code';
|
|
204
|
-
console.
|
|
216
|
+
console.error(
|
|
205
217
|
`Attempting to refresh access token (auth_method: ${this.tokens.auth_method || 'browser'})...`
|
|
206
218
|
);
|
|
207
219
|
|
|
208
220
|
const refreshParams = {
|
|
209
|
-
client_id: this.
|
|
221
|
+
client_id: this.getClientId(),
|
|
210
222
|
grant_type: 'refresh_token',
|
|
211
223
|
refresh_token: this.tokens.refresh_token,
|
|
212
224
|
// Use the GRANTED scopes, not the full configured set. After a base-only
|
|
@@ -247,7 +259,9 @@ class TokenStorage {
|
|
|
247
259
|
Date.now() + responseBody.expires_in * 1000;
|
|
248
260
|
try {
|
|
249
261
|
await this._saveTokensToFile();
|
|
250
|
-
console.
|
|
262
|
+
console.error(
|
|
263
|
+
'Access token refreshed and saved successfully.'
|
|
264
|
+
);
|
|
251
265
|
resolve(this.tokens);
|
|
252
266
|
} catch (saveError) {
|
|
253
267
|
console.error('Failed to save refreshed tokens:', saveError);
|
|
@@ -298,15 +312,16 @@ class TokenStorage {
|
|
|
298
312
|
}
|
|
299
313
|
|
|
300
314
|
async exchangeCodeForTokens(authCode) {
|
|
301
|
-
|
|
315
|
+
const clientId = this.getClientId();
|
|
316
|
+
if (!clientId || !this.config.clientSecret) {
|
|
302
317
|
throw new Error(
|
|
303
318
|
'Client ID or Client Secret is not configured. Cannot exchange code for tokens.'
|
|
304
319
|
);
|
|
305
320
|
}
|
|
306
|
-
console.
|
|
321
|
+
console.error('Exchanging authorization code for tokens...');
|
|
307
322
|
const requestedScopes = this.config.scopes;
|
|
308
323
|
const postData = querystring.stringify({
|
|
309
|
-
client_id:
|
|
324
|
+
client_id: clientId,
|
|
310
325
|
client_secret: this.config.clientSecret,
|
|
311
326
|
grant_type: 'authorization_code',
|
|
312
327
|
code: authCode,
|
|
@@ -351,7 +366,7 @@ class TokenStorage {
|
|
|
351
366
|
};
|
|
352
367
|
try {
|
|
353
368
|
await this._saveTokensToFile();
|
|
354
|
-
console.
|
|
369
|
+
console.error('Tokens exchanged and saved successfully.');
|
|
355
370
|
resolve(this.tokens);
|
|
356
371
|
} catch (saveError) {
|
|
357
372
|
console.error('Failed to save exchanged tokens:', saveError);
|
|
@@ -408,10 +423,10 @@ class TokenStorage {
|
|
|
408
423
|
this.tokens = null;
|
|
409
424
|
try {
|
|
410
425
|
await fs.unlink(this.config.tokenStorePath);
|
|
411
|
-
console.
|
|
426
|
+
console.error('Token file deleted successfully.');
|
|
412
427
|
} catch (error) {
|
|
413
428
|
if (error.code === 'ENOENT') {
|
|
414
|
-
console.
|
|
429
|
+
console.error('Token file not found, nothing to delete.');
|
|
415
430
|
} else {
|
|
416
431
|
console.error('Error deleting token file:', error);
|
|
417
432
|
}
|