Compare commits
No commits in common. "c112c9b330019e1c67463d8c47f2c98616623ad5" and "4d8ea1b3a7c7d054149bcbb7b0cafdd9ddda95aa" have entirely different histories.
c112c9b330
...
4d8ea1b3a7
26 changed files with 412 additions and 1473 deletions
25
.env.example
25
.env.example
|
|
@ -12,28 +12,7 @@ USE_TEST_MODE=false
|
|||
# Optional: Enable verbose debug logging
|
||||
DEBUG_MODE=false
|
||||
|
||||
# Optional: Default timezone for calendar event creation and display (IANA tz name).
|
||||
# Optional: Default timezone for calendar event creation (IANA tz name).
|
||||
# Examples: 'America/New_York', 'Europe/London', 'Australia/Sydney'
|
||||
# Defaults to America/New_York if not set.
|
||||
# Defaults to Eastern Standard Time if not set.
|
||||
# MS_TIMEZONE=America/New_York
|
||||
|
||||
# Optional: shared / delegated mailbox support (default true).
|
||||
# Set false on personal-account instances if shared scopes are unwanted.
|
||||
# OUTLOOK_ENABLE_SHARED_MAILBOXES=true
|
||||
|
||||
# Optional: comma-separated seed list of shared mailbox UPNs/SMTPs for list-mailboxes
|
||||
# OUTLOOK_SHARED_MAILBOXES=helpdesk@contoso.com,it@contoso.com
|
||||
|
||||
# Optional: override probe cache path (default: ${OUTLOOK_TOKEN_STORE_PATH}.mailboxes.json)
|
||||
# OUTLOOK_MAILBOX_CACHE_PATH=
|
||||
|
||||
# Optional: parallel probe concurrency for list-mailboxes
|
||||
# OUTLOOK_MAILBOX_PROBE_CONCURRENCY=4
|
||||
|
||||
# Optional: multi-instance token cache path
|
||||
# OUTLOOK_TOKEN_STORE_PATH=
|
||||
|
||||
# Optional: Graph throttle retry knobs
|
||||
# OUTLOOK_MAX_RETRIES=3
|
||||
# OUTLOOK_BASE_RETRY_DELAY_MS=1000
|
||||
# OUTLOOK_MAX_RETRY_DELAY_MS=30000
|
||||
|
|
|
|||
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -1,31 +0,0 @@
|
|||
# Changelog
|
||||
|
||||
## 1.1.0
|
||||
|
||||
### Added
|
||||
- Shared/delegated mailbox support via optional `mailbox` (UPN/SMTP) on email, folder, and thread tools
|
||||
- Graph path routing `me/...` → `users/{upn}/...` with `X-AnchorMailbox` header
|
||||
- `list-mailboxes` tool: primary + `OUTLOOK_SHARED_MAILBOXES` + cache + `candidates[]`, non-destructive read/folder probes
|
||||
- Local mailbox probe cache (`${tokenStore}.mailboxes.json` or `OUTLOOK_MAILBOX_CACHE_PATH`)
|
||||
- `send-email` options: `mailbox`, `from`, `onBehalfOf` (mailbox-rooted send vs me-rooted send-on-behalf)
|
||||
- `check-auth-status` reports whether `Mail.Read.Shared` / `Mail.ReadWrite.Shared` / `Mail.Send.Shared` are on the token `scp`
|
||||
- Env: `OUTLOOK_ENABLE_SHARED_MAILBOXES`, `OUTLOOK_SHARED_MAILBOXES`, `OUTLOOK_MAILBOX_CACHE_PATH`, `OUTLOOK_MAILBOX_PROBE_CONCURRENCY`
|
||||
- Unit tests: `tests/mailbox-path.test.js`, `tests/mailbox-send-path.test.js`
|
||||
|
||||
### Changed
|
||||
- Version bump to 1.1.0
|
||||
- Default MSAL scopes include shared mailbox delegated permissions when enabled
|
||||
- `callGraphAPI` accepts optional headers; queryParams copy no longer mutates caller `$filter`
|
||||
- `read-email` / `read-emails` no longer double-encode message IDs before path encoding
|
||||
- README: shared mailbox section, Azure shared scopes, env table updates
|
||||
|
||||
### Notes / operator actions
|
||||
- Entra app registration needs new delegated permissions + admin consent
|
||||
- Existing users must re-run device-code `authenticate` after upgrade
|
||||
- Graph cannot enumerate all mailboxes a user can access; seed with env or EXO
|
||||
- Calendar and inbox rules remain primary-mailbox only in this release
|
||||
- `sendAs` capability is unverified until a successful send
|
||||
|
||||
## 1.0.1
|
||||
|
||||
Prior release: Graph empty-body write fix, timezone display/filters, search `strict`, folder list fixes, 429/503 retry/backoff.
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# Draft commit message (do not commit until reviewed)
|
||||
|
||||
```
|
||||
feat(outlook-mcp): shared mailbox targeting, discovery, and scopes (v1.1.0)
|
||||
|
||||
Add optional mailbox (UPN/SMTP) routing on email/folder/thread tools via
|
||||
users/{upn}/... and X-AnchorMailbox. New list-mailboxes probes primary,
|
||||
OUTLOOK_SHARED_MAILBOXES seeds, cache, and candidates. Send supports
|
||||
mailbox-rooted sendMail and onBehalfOf. MSAL requests Mail.*.Shared;
|
||||
check-auth-status reports token scp gaps. Docs, env example, tests.
|
||||
```
|
||||
|
||||
## Files touched (summary)
|
||||
|
||||
New:
|
||||
- utils/mailbox.js
|
||||
- mailbox/list.js, mailbox/index.js
|
||||
- tests/mailbox-path.test.js, tests/mailbox-send-path.test.js
|
||||
- CHANGELOG.md
|
||||
|
||||
Modified:
|
||||
- config.js, package.json, index.js
|
||||
- utils/graph-api.js
|
||||
- email/* (folder-utils, list, search, read, read-multiple, send, index)
|
||||
- folder/* (list, create, move, index)
|
||||
- tools/get-email-thread.js
|
||||
- auth/tools.js
|
||||
- README.md, .env.example
|
||||
|
||||
## Operator follow-up (not in this commit)
|
||||
|
||||
1. Entra: add delegated Mail.Read.Shared, Mail.ReadWrite.Shared, Mail.Send.Shared; admin consent
|
||||
2. hermes gateway restart + session reset so tool schemas reload
|
||||
3. Re-auth work (and personal if shared enabled) via device code
|
||||
4. Optional: set OUTLOOK_SHARED_MAILBOXES on work instance
|
||||
5. Update Hermes skills outlook-mcp-setup / send-email-quirks when ready
|
||||
79
README.md
79
README.md
|
|
@ -7,7 +7,6 @@ Built for use with AI agents (Claude, Hermes, etc.) that support the MCP standar
|
|||
## Features
|
||||
|
||||
- **Email**: List, search, read, send, and reconstruct full conversation threads with quoted-reply stripping and signature deduplication
|
||||
- **Shared mailboxes**: Optional `mailbox` parameter on email/folder tools; `list-mailboxes` probes seeded candidates (Graph cannot enumerate all rights)
|
||||
- **Calendar**: List, create, decline, cancel, and delete events
|
||||
- **Folders**: List (flat or hierarchical), create, and move emails between folders
|
||||
- **Inbox Rules**: List, create, and reorder execution priority of inbox rules
|
||||
|
|
@ -72,21 +71,15 @@ This server uses MSAL **device code flow** with a **public client** app registra
|
|||
- `Mail.Read`
|
||||
- `Mail.ReadWrite`
|
||||
- `Mail.Send`
|
||||
- `Mail.Read.Shared` (shared/delegated mailboxes — work/school only)
|
||||
- `Mail.ReadWrite.Shared`
|
||||
- `Mail.Send.Shared`
|
||||
- `User.Read`
|
||||
- `Calendars.Read`
|
||||
- `Calendars.ReadWrite`
|
||||
- `MailboxSettings.ReadWrite`
|
||||
- `offline_access`
|
||||
7. **Grant admin consent** for the tenant (required for `*.Shared` in most orgs)
|
||||
8. Copy the **Application (client) ID** — that's your `MS_CLIENT_ID`
|
||||
7. Copy the **Application (client) ID** — that's your `MS_CLIENT_ID`
|
||||
|
||||
No client secret is needed for public client apps.
|
||||
|
||||
After adding shared scopes to an existing deployment, users must **re-run device-code authenticate** so the access token's `scp` claim includes the new permissions. `check-auth-status` reports missing shared scopes.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via environment variables:
|
||||
|
|
@ -98,36 +91,7 @@ All configuration is via environment variables:
|
|||
| `USE_TEST_MODE` | No | `false` | Use mock data instead of real API calls |
|
||||
| `DEBUG_MODE` | No | `false` | Verbose logging (MSAL, API calls, working directory) |
|
||||
| `OUTLOOK_TOKEN_STORE_PATH` | No | `~/.outlook-mcp-tokens.json` | Token cache file path (override for multi-instance setups) |
|
||||
| `MS_TIMEZONE` | No | `America/New_York` | Default timezone for calendar/event display and date filters (IANA or Windows name) |
|
||||
| `OUTLOOK_ENABLE_SHARED_MAILBOXES` | No | `true` | When `false`, omit shared scopes and hide `list-mailboxes` |
|
||||
| `OUTLOOK_SHARED_MAILBOXES` | No | empty | Comma-separated seed list of shared mailbox UPNs/SMTPs to probe |
|
||||
| `OUTLOOK_MAILBOX_CACHE_PATH` | No | `${tokenStore}.mailboxes.json` | Probe result cache path |
|
||||
| `OUTLOOK_MAILBOX_PROBE_CONCURRENCY` | No | `4` | Parallel probes in `list-mailboxes` |
|
||||
| `OUTLOOK_MAX_RETRIES` | No | `3` | Graph 429/503 retry count |
|
||||
| `OUTLOOK_BASE_RETRY_DELAY_MS` | No | `1000` | Initial backoff delay |
|
||||
| `OUTLOOK_MAX_RETRY_DELAY_MS` | No | `30000` | Backoff ceiling |
|
||||
|
||||
## Shared mailboxes
|
||||
|
||||
Microsoft Graph **does not** expose an API that lists every mailbox the signed-in user can access. This server supports shared/delegated mailboxes by:
|
||||
|
||||
1. **Targeting** — pass `mailbox: "shared@contoso.com"` on list/search/read/send/folder/thread tools. Calls use `users/{upn}/...` instead of `me/...`, plus an `X-AnchorMailbox` header.
|
||||
2. **Discovery** — `list-mailboxes` merges primary + `OUTLOOK_SHARED_MAILBOXES` + local cache + optional `candidates[]`, then probes read/folder access. `sendAs` stays `unverified` until a successful send.
|
||||
3. **Exchange rights** — Graph scopes are not enough. The user still needs Full Access / Send As / Send on Behalf on the mailbox in Exchange Online.
|
||||
4. **Send** — default when `mailbox` is set: `POST /users/{mailbox}/sendMail`. Set `onBehalfOf: true` to use `me/sendMail` with `from` set to the shared address.
|
||||
|
||||
Message IDs are **mailbox-scoped**. Always pass the same `mailbox` value on follow-up read/thread calls.
|
||||
|
||||
Optional EXO admin seed (outside this MCP):
|
||||
|
||||
```powershell
|
||||
Get-EXOMailbox -RecipientTypeDetails SharedMailbox -ResultSize Unlimited |
|
||||
ForEach-Object {
|
||||
Get-EXOMailboxPermission -Identity $_.Identity -User $user -ErrorAction SilentlyContinue
|
||||
}
|
||||
```
|
||||
|
||||
Pipe known addresses into `OUTLOOK_SHARED_MAILBOXES`.
|
||||
| `MS_TIMEZONE` | No | `Eastern Standard Time` | Default timezone for calendar event creation (IANA or Windows timezone name) |
|
||||
|
||||
## MCP Client Configuration
|
||||
|
||||
|
|
@ -186,7 +150,7 @@ Each instance gets its own MSAL cache — no account-selection conflicts.
|
|||
|
||||
## Tools Reference
|
||||
|
||||
The server exposes **22 tools** across six categories (21 when `OUTLOOK_ENABLE_SHARED_MAILBOXES=false`).
|
||||
The server exposes **21 tools** across five categories.
|
||||
|
||||
### Authentication (3 tools)
|
||||
|
||||
|
|
@ -200,12 +164,12 @@ The server exposes **22 tools** across six categories (21 when `OUTLOOK_ENABLE_S
|
|||
|
||||
| Tool | Key Parameters | Description |
|
||||
|---|---|---|
|
||||
| `list-emails` | `folder`, `count`, `dateFrom`, `dateTo`, `dateRange`, `mailbox` | Lists emails from a folder with date filtering. Results include `conversationId`. Pass `mailbox` to target a shared mailbox. |
|
||||
| `search-emails` | `query`, `from`, `to`, `subject`, `hasAttachments`, `unreadOnly`, `count`, `strict`, `mailbox` | Progressive search with KQL fallback strategies. Results include `conversationId`. Pass `mailbox` for shared mailbox search. Use `strict: true` to disable fallback to recent emails. |
|
||||
| `read-email` | `id`, `mailbox` | Reads a single email with full body (auto-cleaned HTML → text). Pass the same `mailbox` used when listing/searching. |
|
||||
| `read-emails` | `ids` (max 10), `mailbox` | Reads multiple emails concurrently |
|
||||
| `send-email` | `to`, `cc`, `bcc`, `subject`, `body`, `importance`, `saveToSentItems`, `mailbox`, `from`, `onBehalfOf` | Sends an email (plain text or HTML). When `mailbox` is set, sends as that shared mailbox via `users/{mailbox}/sendMail` (requires Send As + `Mail.Send.Shared`). Set `onBehalfOf: true` for Send on Behalf via `me/sendMail` with `from` set. |
|
||||
| `get-email-thread` | `conversationId` (preferred) or `ids` (max 20), `mailbox` | Fetches a complete conversation across all folders (inbox + sent), strips quoted replies, deduplicates signatures per sender. Pass `mailbox` when the conversation is in a shared mailbox. |
|
||||
| `list-emails` | `folder`, `count`, `dateFrom`, `dateTo`, `dateRange` | Lists emails from a folder with date filtering. Results include `conversationId`. |
|
||||
| `search-emails` | `query`, `from`, `to`, `subject`, `hasAttachments`, `unreadOnly`, `count` | Progressive search with KQL fallback strategies. Results include `conversationId`. |
|
||||
| `read-email` | `id` | Reads a single email with full body (auto-cleaned HTML → text) |
|
||||
| `read-emails` | `ids` (max 10) | Reads multiple emails concurrently |
|
||||
| `send-email` | `to`, `cc`, `bcc`, `subject`, `body`, `importance`, `saveToSentItems` | Sends an email (plain text or HTML) |
|
||||
| `get-email-thread` | `conversationId` (preferred) or `ids` (max 20) | Fetches a complete conversation across all folders (inbox + sent), strips quoted replies, deduplicates signatures per sender |
|
||||
|
||||
**Date filtering** supports both ISO dates (`2024-06-15`) and relative ranges (`today`, `yesterday`, `last7days`, `last30days`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`).
|
||||
|
||||
|
|
@ -226,9 +190,9 @@ The server exposes **22 tools** across six categories (21 when `OUTLOOK_ENABLE_S
|
|||
|
||||
| Tool | Key Parameters | Description |
|
||||
|---|---|---|
|
||||
| `list-folders` | `includeItemCounts`, `includeChildren`, `mailbox` | Lists mail folders (flat list or hierarchical tree). Pass `mailbox` for a shared mailbox's folders. |
|
||||
| `create-folder` | `name`, `parentFolder`, `mailbox` | Creates a new mail folder (optionally nested under a parent). There is no delete-folder tool — avoid test folders on shared mailboxes. |
|
||||
| `move-emails` | `emailIds`, `targetFolder`, `sourceFolder`, `mailbox` | Moves emails to a target folder by name within the same mailbox |
|
||||
| `list-folders` | `includeItemCounts`, `includeChildren` | Lists mail folders (flat list or hierarchical tree) |
|
||||
| `create-folder` | `name`, `parentFolder` | Creates a new mail folder (optionally nested under a parent) |
|
||||
| `move-emails` | `emailIds`, `targetFolder`, `sourceFolder` | Moves emails to a target folder by name |
|
||||
|
||||
### Inbox Rules (3 tools)
|
||||
|
||||
|
|
@ -238,12 +202,6 @@ The server exposes **22 tools** across six categories (21 when `OUTLOOK_ENABLE_S
|
|||
| `create-rule` | `name`, `fromAddresses`, `containsSubject`, `hasAttachments`, `moveToFolder`, `markAsRead`, `isEnabled`, `sequence` | Creates a new inbox rule |
|
||||
| `edit-rule-sequence` | `ruleName`, `sequence` | Changes the execution order of an existing rule |
|
||||
|
||||
### Mailbox (1 tool, hidden when `OUTLOOK_ENABLE_SHARED_MAILBOXES=false`)
|
||||
|
||||
| Tool | Key Parameters | Description |
|
||||
|---|---|---|
|
||||
| `list-mailboxes` | `candidates` | Probes primary mailbox + `OUTLOOK_SHARED_MAILBOXES` seeds + local cache + optional `candidates[]` for read/folder access. Reports `sendAs` as `unverified` until a successful send. Graph cannot enumerate all mailboxes the user has rights to. |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
|
@ -280,10 +238,6 @@ folder/
|
|||
create.js # create-folder handler
|
||||
move.js # move-emails handler
|
||||
|
||||
mailbox/
|
||||
index.js # Tool definition for list-mailboxes
|
||||
list.js # list-mailboxes handler — probes primary, seeds, cache, candidates
|
||||
|
||||
rules/
|
||||
index.js # Tool definitions for rules tools + edit-rule-sequence handler
|
||||
list.js # list-rules handler + getInboxRules() utility
|
||||
|
|
@ -293,12 +247,9 @@ tools/
|
|||
get-email-thread.js # get-email-thread tool — conversationId-based thread fetcher
|
||||
|
||||
utils/
|
||||
graph-api.js # callGraphAPI() — HTTPS client for Microsoft Graph (429/503 retry/backoff)
|
||||
mailbox.js # normalizeMailbox(), buildPath(), withMailboxHeaders() — shared mailbox routing
|
||||
graph-api.js # callGraphAPI() — HTTPS client for Microsoft Graph
|
||||
bodyParser.js # HTML → text conversion, boilerplate/banner/legal block stripping, signature handling
|
||||
threadBuilder.js # Quote-boundary detection, unique-content extraction, chronological thread formatting
|
||||
time-formatter.js # Timezone-aware timestamp formatting for email/calendar display
|
||||
timezone-mapper.js # Maps Windows timezone names to IANA for Intl-based date operations
|
||||
odata-helpers.js # OData filter building, date parsing, relative date ranges
|
||||
mock-data.js # Test mode mock responses
|
||||
```
|
||||
|
|
@ -337,8 +288,8 @@ Launches the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspecto
|
|||
|
||||
- **Token cache**: The `.outlook-mcp-tokens.json` file contains access and refresh tokens. It's in `.gitignore` by default. Protect it like a password.
|
||||
- **No client secret in code**: For public client apps, no secret is needed. If you use a confidential client app, set `MS_CLIENT_SECRET` in the environment (not in the code).
|
||||
- **Scopes are conditional**: The OAuth scopes are defined in `config.js`. When `OUTLOOK_ENABLE_SHARED_MAILBOXES` is not `false`, the scope set includes `Mail.Read.Shared`, `Mail.ReadWrite.Shared`, and `Mail.Send.Shared`. Disable shared mailboxes to request a narrower scope set.
|
||||
- **`.gitignore` covers**: `node_modules/`, `.env` / `.env.*`, `*.pem`, `*.key`, `*.cert`, `*.token.json`, `.outlook-mcp-tokens.json`
|
||||
- **Scopes are hardcoded**: The OAuth scopes are defined in `config.js` and include read/write for mail and calendar. Adjust if you need fewer permissions.
|
||||
- **`.gitignore` covers**: `node_modules/`, `.env*`, `*.token.json`, `*.pem`, `*.key`, `.outlook-mcp-tokens.json`
|
||||
|
||||
## Known Issues
|
||||
|
||||
|
|
|
|||
|
|
@ -3,16 +3,12 @@
|
|||
*/
|
||||
const config = require('../config');
|
||||
const tokenManager = require('./token-manager');
|
||||
const { analyzeTokenScopes, expectedSharedScopes } = require('../utils/mailbox');
|
||||
|
||||
async function handleAbout() {
|
||||
const sharedLine = config.ENABLE_SHARED_MAILBOXES
|
||||
? 'Shared mailbox support: enabled (optional mailbox param + list-mailboxes). Requires Mail.*.Shared scopes and re-auth after upgrade.'
|
||||
: 'Shared mailbox support: disabled (OUTLOOK_ENABLE_SHARED_MAILBOXES=false).';
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Outlook Assistant MCP Server v${config.SERVER_VERSION}\n\nProvides access to Microsoft Outlook email, calendar, folders, and rules through Microsoft Graph API.\n${sharedLine}`
|
||||
text: `Outlook Assistant MCP Server v${config.SERVER_VERSION}\n\nProvides access to Microsoft Outlook email, calendar, and contacts through Microsoft Graph API.`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
|
@ -52,10 +48,6 @@ async function handleAuthenticate(args) {
|
|||
console.error(`[authenticate] Device code flow ended: ${err.message}`);
|
||||
});
|
||||
|
||||
const scopeHint = config.ENABLE_SHARED_MAILBOXES
|
||||
? `\nThis login requests shared-mailbox scopes: ${expectedSharedScopes().join(', ')}.\nAfter signing in, call check-auth-status and confirm those scopes are present.`
|
||||
: '';
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
|
|
@ -66,9 +58,8 @@ async function handleAuthenticate(args) {
|
|||
` 2. Enter code: ${userCode}`,
|
||||
``,
|
||||
`You have ${minutesRemaining} minutes to complete sign-in.`,
|
||||
`After signing in, call check-auth-status to confirm.`,
|
||||
scopeHint
|
||||
].filter(Boolean).join('\n')
|
||||
`After signing in, call check-auth-status to confirm.`
|
||||
].join('\n')
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
|
@ -82,30 +73,7 @@ async function handleCheckAuthStatus() {
|
|||
return { content: [{ type: "text", text: "Not authenticated" }] };
|
||||
}
|
||||
console.error('[CHECK-AUTH-STATUS] Valid token acquired');
|
||||
|
||||
const lines = ['Authenticated and ready'];
|
||||
|
||||
if (config.ENABLE_SHARED_MAILBOXES) {
|
||||
const analysis = analyzeTokenScopes(token);
|
||||
if (analysis.missing.length === 0) {
|
||||
lines.push('Shared mailbox scopes: OK (' + analysis.present.join(', ') + ')');
|
||||
} else if (analysis.present.length === 0) {
|
||||
lines.push('Shared mailbox scopes: MISSING (' + analysis.missing.join(', ') + ')');
|
||||
lines.push('Re-run authenticate (device code) after adding these delegated permissions in Entra and granting admin consent.');
|
||||
} else {
|
||||
lines.push('Shared mailbox scopes: PARTIAL');
|
||||
lines.push(' present: ' + analysis.present.join(', '));
|
||||
lines.push(' missing: ' + analysis.missing.join(', '));
|
||||
lines.push('Re-run authenticate so the token picks up the missing scopes.');
|
||||
}
|
||||
if (analysis.scp) {
|
||||
lines.push('Token scp: ' + analysis.scp);
|
||||
}
|
||||
} else {
|
||||
lines.push('Shared mailbox feature disabled (OUTLOOK_ENABLE_SHARED_MAILBOXES=false).');
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: lines.join('\n') }] };
|
||||
return { content: [{ type: "text", text: "Authenticated and ready" }] };
|
||||
} catch (e) {
|
||||
console.error('[CHECK-AUTH-STATUS] Error:', e.message);
|
||||
return { content: [{ type: "text", text: "Not authenticated" }] };
|
||||
|
|
@ -121,7 +89,7 @@ const authTools = [
|
|||
},
|
||||
{
|
||||
name: "authenticate",
|
||||
description: "Authenticate with Microsoft Graph API using device code flow. Returns a short code and URL. IMPORTANT: Show the user the code and URL — they must visit the URL on any device and enter the code to complete sign-in. After they sign in, call check-auth-status to confirm. After upgrading to shared-mailbox support, re-authenticate so Mail.*.Shared scopes appear on the token.",
|
||||
description: "Authenticate with Microsoft Graph API using device code flow. Returns a short code and URL. IMPORTANT: Show the user the code and URL — they must visit the URL on any device and enter the code to complete sign-in. After they sign in, call check-auth-status to confirm.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -136,7 +104,7 @@ const authTools = [
|
|||
},
|
||||
{
|
||||
name: "check-auth-status",
|
||||
description: "Check the current authentication status with Microsoft Graph API, including whether shared-mailbox scopes are present on the access token",
|
||||
description: "Check the current authentication status with Microsoft Graph API",
|
||||
inputSchema: { type: "object", properties: {}, required: [] },
|
||||
handler: handleCheckAuthStatus
|
||||
}
|
||||
|
|
|
|||
29
config.js
29
config.js
|
|
@ -7,27 +7,10 @@ const os = require('os');
|
|||
// Ensure we have a home directory path even if process.env.HOME is undefined
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir() || '/tmp';
|
||||
|
||||
const enableSharedMailboxes = process.env.OUTLOOK_ENABLE_SHARED_MAILBOXES !== 'false';
|
||||
|
||||
const baseScopes = [
|
||||
'Mail.Read',
|
||||
'Mail.ReadWrite',
|
||||
'Mail.Send',
|
||||
'User.Read',
|
||||
'Calendars.Read',
|
||||
'Calendars.ReadWrite',
|
||||
'MailboxSettings.ReadWrite',
|
||||
'offline_access'
|
||||
];
|
||||
|
||||
const sharedScopes = enableSharedMailboxes
|
||||
? ['Mail.Read.Shared', 'Mail.ReadWrite.Shared', 'Mail.Send.Shared']
|
||||
: [];
|
||||
|
||||
module.exports = {
|
||||
// Server information
|
||||
SERVER_NAME: "outlook-assistant-main",
|
||||
SERVER_VERSION: "1.1.0",
|
||||
SERVER_VERSION: "1.0.1",
|
||||
|
||||
// Test mode setting
|
||||
USE_TEST_MODE: process.env.USE_TEST_MODE === 'true',
|
||||
|
|
@ -35,16 +18,13 @@ module.exports = {
|
|||
// Debug mode setting
|
||||
DEBUG_MODE: process.env.DEBUG_MODE === 'true',
|
||||
|
||||
// Shared / delegated mailbox feature flag (scopes + list-mailboxes tool)
|
||||
ENABLE_SHARED_MAILBOXES: enableSharedMailboxes,
|
||||
|
||||
// Authentication configuration
|
||||
AUTH_CONFIG: {
|
||||
clientId: process.env.MS_CLIENT_ID || '',
|
||||
// Optional: set MS_CLIENT_SECRET for confidential client app registrations.
|
||||
// Public client apps (Allow public client flows enabled, no secret) leave this blank.
|
||||
clientSecret: process.env.MS_CLIENT_SECRET || '',
|
||||
scopes: [...baseScopes, ...sharedScopes],
|
||||
scopes: ['Mail.Read', 'Mail.ReadWrite', 'Mail.Send', 'User.Read', 'Calendars.Read', 'Calendars.ReadWrite', 'MailboxSettings.ReadWrite', 'offline_access'],
|
||||
tokenStorePath: process.env.OUTLOOK_TOKEN_STORE_PATH || path.join(homeDir, '.outlook-mcp-tokens.json'),
|
||||
// Device code flow: polling interval in seconds (Microsoft returns the recommended interval)
|
||||
deviceCodePollingInterval: 5
|
||||
|
|
@ -70,8 +50,5 @@ module.exports = {
|
|||
// Honor Retry-After when Graph sends it; otherwise use exponential backoff.
|
||||
MAX_RETRIES: parseInt(process.env.OUTLOOK_MAX_RETRIES, 10) || 3,
|
||||
BASE_RETRY_DELAY_MS: parseInt(process.env.OUTLOOK_BASE_RETRY_DELAY_MS, 10) || 1000,
|
||||
MAX_RETRY_DELAY_MS: parseInt(process.env.OUTLOOK_MAX_RETRY_DELAY_MS, 10) || 30000,
|
||||
|
||||
// Shared mailbox probe concurrency
|
||||
MAILBOX_PROBE_CONCURRENCY: parseInt(process.env.OUTLOOK_MAILBOX_PROBE_CONCURRENCY, 10) || 4
|
||||
MAX_RETRY_DELAY_MS: parseInt(process.env.OUTLOOK_MAX_RETRY_DELAY_MS, 10) || 30000
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
* Email folder utilities
|
||||
*/
|
||||
const { callGraphAPI } = require('../utils/graph-api');
|
||||
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
|
||||
|
||||
/**
|
||||
* Cache of folder information to reduce API calls
|
||||
|
|
@ -14,54 +13,46 @@ const folderCache = {};
|
|||
* Resolve a folder name to its endpoint path
|
||||
* @param {string} accessToken - Access token
|
||||
* @param {string} folderName - Folder name to resolve
|
||||
* @param {string|object|null} mailbox - Optional shared mailbox UPN/SMTP or normalizeMailbox ctx
|
||||
* @returns {Promise<string>} - Resolved endpoint path
|
||||
*/
|
||||
async function resolveFolderPath(accessToken, folderName, mailbox = null) {
|
||||
const mb = typeof mailbox === 'object' && mailbox && mailbox.graphRoot
|
||||
? mailbox
|
||||
: normalizeMailbox(mailbox);
|
||||
const headers = { headers: withMailboxHeaders(mb) };
|
||||
|
||||
async function resolveFolderPath(accessToken, folderName) {
|
||||
// Default to inbox if no folder specified
|
||||
if (!folderName) {
|
||||
return buildPath(mb, 'messages');
|
||||
return 'me/messages';
|
||||
}
|
||||
|
||||
// Handle well-known folder names (case-insensitive)
|
||||
// Note: 'inbox' uses the messages shortcut; SentItems aliases included for search UX
|
||||
const wellKnownRelative = {
|
||||
'inbox': 'messages',
|
||||
'drafts': 'mailFolders/drafts/messages',
|
||||
'sent': 'mailFolders/sentItems/messages',
|
||||
'sentitems': 'mailFolders/sentItems/messages',
|
||||
'deleted': 'mailFolders/deletedItems/messages',
|
||||
'deleteditems': 'mailFolders/deletedItems/messages',
|
||||
'junk': 'mailFolders/junkemail/messages',
|
||||
'junkemail': 'mailFolders/junkemail/messages',
|
||||
'archive': 'mailFolders/archive/messages'
|
||||
// Handle well-known folder names
|
||||
const wellKnownFolders = {
|
||||
'inbox': 'me/messages',
|
||||
'drafts': 'me/mailFolders/drafts/messages',
|
||||
'sent': 'me/mailFolders/sentItems/messages',
|
||||
'deleted': 'me/mailFolders/deletedItems/messages',
|
||||
'junk': 'me/mailFolders/junkemail/messages',
|
||||
'archive': 'me/mailFolders/archive/messages'
|
||||
};
|
||||
|
||||
// Check if it's a well-known folder (case-insensitive)
|
||||
const lowerFolderName = folderName.toLowerCase();
|
||||
if (wellKnownRelative[lowerFolderName]) {
|
||||
const p = buildPath(mb, wellKnownRelative[lowerFolderName]);
|
||||
console.error(`Using well-known folder path for "${folderName}": ${p}`);
|
||||
return p;
|
||||
if (wellKnownFolders[lowerFolderName]) {
|
||||
console.error(`Using well-known folder path for "${folderName}"`);
|
||||
return wellKnownFolders[lowerFolderName];
|
||||
}
|
||||
|
||||
try {
|
||||
const folderId = await getFolderIdByName(accessToken, folderName, mb);
|
||||
// Try to find the folder by name
|
||||
const folderId = await getFolderIdByName(accessToken, folderName);
|
||||
if (folderId) {
|
||||
const p = buildPath(mb, `mailFolders/${folderId}/messages`);
|
||||
console.error(`Resolved folder "${folderName}" to path: ${p}`);
|
||||
return p;
|
||||
const path = `me/mailFolders/${folderId}/messages`;
|
||||
console.error(`Resolved folder "${folderName}" to path: ${path}`);
|
||||
return path;
|
||||
}
|
||||
|
||||
// If not found, fall back to inbox
|
||||
console.error(`Couldn't find folder "${folderName}", falling back to inbox`);
|
||||
return buildPath(mb, 'messages');
|
||||
return 'me/messages';
|
||||
} catch (error) {
|
||||
console.error(`Error resolving folder "${folderName}": ${error.message}`);
|
||||
return buildPath(mb, 'messages');
|
||||
return 'me/messages';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,24 +60,18 @@ async function resolveFolderPath(accessToken, folderName, mailbox = null) {
|
|||
* Get the ID of a mail folder by its name
|
||||
* @param {string} accessToken - Access token
|
||||
* @param {string} folderName - Name of the folder to find
|
||||
* @param {string|object|null} mailbox
|
||||
* @returns {Promise<string|null>} - Folder ID or null if not found
|
||||
*/
|
||||
async function getFolderIdByName(accessToken, folderName, mailbox = null) {
|
||||
const mb = typeof mailbox === 'object' && mailbox && mailbox.graphRoot
|
||||
? mailbox
|
||||
: normalizeMailbox(mailbox);
|
||||
const opts = { headers: withMailboxHeaders(mb) };
|
||||
|
||||
async function getFolderIdByName(accessToken, folderName) {
|
||||
try {
|
||||
console.error(`Looking for folder with name "${folderName}" in ${mb.graphRoot}`);
|
||||
// First try with exact match filter
|
||||
console.error(`Looking for folder with name "${folderName}"`);
|
||||
const response = await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
buildPath(mb, 'mailFolders'),
|
||||
'me/mailFolders',
|
||||
null,
|
||||
{ $filter: `displayName eq '${folderName}'` },
|
||||
opts
|
||||
{ $filter: `displayName eq '${folderName}'` }
|
||||
);
|
||||
|
||||
if (response.value && response.value.length > 0) {
|
||||
|
|
@ -94,14 +79,14 @@ async function getFolderIdByName(accessToken, folderName, mailbox = null) {
|
|||
return response.value[0].id;
|
||||
}
|
||||
|
||||
// If exact match fails, try to get all folders and do a case-insensitive comparison
|
||||
console.error(`No exact match found for "${folderName}", trying case-insensitive search`);
|
||||
const allFoldersResponse = await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
buildPath(mb, 'mailFolders'),
|
||||
'me/mailFolders',
|
||||
null,
|
||||
{ $top: 100 },
|
||||
opts
|
||||
{ $top: 100 }
|
||||
);
|
||||
|
||||
if (allFoldersResponse.value) {
|
||||
|
|
@ -127,32 +112,27 @@ async function getFolderIdByName(accessToken, folderName, mailbox = null) {
|
|||
/**
|
||||
* Get all mail folders
|
||||
* @param {string} accessToken - Access token
|
||||
* @param {string|object|null} mailbox
|
||||
* @returns {Promise<Array>} - Array of folder objects
|
||||
*/
|
||||
async function getAllFolders(accessToken, mailbox = null) {
|
||||
const mb = typeof mailbox === 'object' && mailbox && mailbox.graphRoot
|
||||
? mailbox
|
||||
: normalizeMailbox(mailbox);
|
||||
const opts = { headers: withMailboxHeaders(mb) };
|
||||
|
||||
async function getAllFolders(accessToken) {
|
||||
try {
|
||||
// Get top-level folders
|
||||
const response = await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
buildPath(mb, 'mailFolders'),
|
||||
'me/mailFolders',
|
||||
null,
|
||||
{
|
||||
$top: 100,
|
||||
$select: 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount'
|
||||
},
|
||||
opts
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get child folders for folders with children
|
||||
const foldersWithChildren = response.value.filter(f => f.childFolderCount > 0);
|
||||
|
||||
const childFolderPromises = foldersWithChildren.map(async (folder) => {
|
||||
|
|
@ -160,12 +140,11 @@ async function getAllFolders(accessToken, mailbox = null) {
|
|||
const childResponse = await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
buildPath(mb, `mailFolders/${folder.id}/childFolders`),
|
||||
`me/mailFolders/${folder.id}/childFolders`,
|
||||
null,
|
||||
{
|
||||
$select: 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount'
|
||||
},
|
||||
opts
|
||||
}
|
||||
);
|
||||
|
||||
return childResponse.value || [];
|
||||
|
|
@ -177,6 +156,7 @@ async function getAllFolders(accessToken, mailbox = null) {
|
|||
|
||||
const childFolders = await Promise.all(childFolderPromises);
|
||||
|
||||
// Combine top-level folders and all child folders
|
||||
return [...response.value, ...childFolders.flat()];
|
||||
} catch (error) {
|
||||
console.error(`Error getting all folders: ${error.message}`);
|
||||
|
|
@ -187,6 +167,5 @@ async function getAllFolders(accessToken, mailbox = null) {
|
|||
module.exports = {
|
||||
resolveFolderPath,
|
||||
getFolderIdByName,
|
||||
getAllFolders,
|
||||
folderCache
|
||||
getAllFolders
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,16 +7,11 @@ const handleReadEmail = require('./read');
|
|||
const handleReadMultipleEmails = require('./read-multiple');
|
||||
const handleSendEmail = require('./send');
|
||||
|
||||
const mailboxProp = {
|
||||
type: "string",
|
||||
description: "Optional shared/delegated mailbox UPN or SMTP (e.g. 'helpdesk@contoso.com'). Omit for the signed-in user's primary mailbox. Message IDs are mailbox-scoped — pass the same mailbox on read/thread calls."
|
||||
};
|
||||
|
||||
// Email tool definitions
|
||||
const emailTools = [
|
||||
{
|
||||
name: "list-emails",
|
||||
description: "Lists recent emails from a folder. Results include 'conversationId' which can be passed to 'get-email-thread' to retrieve a full thread. Optional mailbox targets a shared mailbox.",
|
||||
description: "Lists recent emails from a folder. Results include 'conversationId' which can be passed to 'get-email-thread' to retrieve a full thread.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -39,8 +34,7 @@ const emailTools = [
|
|||
dateRange: {
|
||||
type: "string",
|
||||
description: "Predefined date range ('today', 'yesterday', 'last7days', 'last30days', 'thisweek', 'lastweek', 'thismonth', 'lastmonth')"
|
||||
},
|
||||
mailbox: mailboxProp
|
||||
}
|
||||
},
|
||||
required: []
|
||||
},
|
||||
|
|
@ -48,7 +42,7 @@ const emailTools = [
|
|||
},
|
||||
{
|
||||
name: "search-emails",
|
||||
description: "Search for emails by sender, subject, keywords, or filters. Results include 'conversationId' — pass it to 'get-email-thread' to read the full thread. Optional mailbox targets a shared mailbox. If no matching emails are found, use 'list-emails' to browse recent mail instead.",
|
||||
description: "Search for emails by sender, subject, keywords, or filters. Results include 'conversationId' — pass it to 'get-email-thread' to read the full thread. If no matching emails are found, use 'list-emails' to browse recent mail instead.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -99,8 +93,7 @@ const emailTools = [
|
|||
strict: {
|
||||
anyOf: [{ type: "boolean" }, { type: "string" }],
|
||||
description: "Set true to disable the fallback to recent emails when no exact search matches are found"
|
||||
},
|
||||
mailbox: mailboxProp
|
||||
}
|
||||
},
|
||||
required: []
|
||||
},
|
||||
|
|
@ -108,15 +101,14 @@ const emailTools = [
|
|||
},
|
||||
{
|
||||
name: "read-email",
|
||||
description: "Reads the content of a specific email. If the message came from a shared mailbox, pass the same mailbox value.",
|
||||
description: "Reads the content of a specific email",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "string",
|
||||
description: "ID of the email to read"
|
||||
},
|
||||
mailbox: mailboxProp
|
||||
}
|
||||
},
|
||||
required: ["id"]
|
||||
},
|
||||
|
|
@ -124,7 +116,7 @@ const emailTools = [
|
|||
},
|
||||
{
|
||||
name: "read-emails",
|
||||
description: "Reads the content of multiple emails at once. If IDs came from a shared mailbox, pass the same mailbox value.",
|
||||
description: "Reads the content of multiple emails at once",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -134,8 +126,7 @@ const emailTools = [
|
|||
type: "string"
|
||||
},
|
||||
description: "Array of email IDs to read (max: 10)"
|
||||
},
|
||||
mailbox: mailboxProp
|
||||
}
|
||||
},
|
||||
required: ["ids"]
|
||||
},
|
||||
|
|
@ -143,7 +134,7 @@ const emailTools = [
|
|||
},
|
||||
{
|
||||
name: "send-email",
|
||||
description: "Composes and sends a new email. Optional mailbox sends as that shared mailbox (requires Exchange Send As + Mail.Send.Shared). Use onBehalfOf=true for Send on Behalf via me/sendMail with from set.",
|
||||
description: "Composes and sends a new email",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -175,15 +166,6 @@ const emailTools = [
|
|||
saveToSentItems: {
|
||||
type: "boolean",
|
||||
description: "Whether to save the email to sent items"
|
||||
},
|
||||
mailbox: mailboxProp,
|
||||
from: {
|
||||
type: "string",
|
||||
description: "Optional From SMTP. Defaults to mailbox when mailbox is set."
|
||||
},
|
||||
onBehalfOf: {
|
||||
anyOf: [{ type: "boolean" }, { type: "string" }],
|
||||
description: "If true, send via me/sendMail with from=shared (Send on Behalf style). Default false uses users/{mailbox}/sendMail when mailbox is set."
|
||||
}
|
||||
},
|
||||
required: ["to", "subject", "body"]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ const { callGraphAPI } = require('../utils/graph-api');
|
|||
const { ensureAuthenticated } = require('../auth');
|
||||
const { buildODataFilter, buildDateFilter } = require('../utils/odata-helpers');
|
||||
const { resolveFolderPath } = require('./folder-utils');
|
||||
const { normalizeMailbox, withMailboxHeaders } = require('../utils/mailbox');
|
||||
|
||||
/**
|
||||
* List emails handler
|
||||
|
|
@ -18,36 +17,40 @@ const { normalizeMailbox, withMailboxHeaders } = require('../utils/mailbox');
|
|||
async function handleListEmails(args) {
|
||||
const folder = args.folder || "inbox";
|
||||
const count = Math.min(parseInt(args.count, 10) || 10, config.MAX_RESULT_COUNT);
|
||||
const mb = normalizeMailbox(args.mailbox);
|
||||
|
||||
try {
|
||||
// Get access token
|
||||
const accessToken = await ensureAuthenticated();
|
||||
const endpoint = await resolveFolderPath(accessToken, folder, mb);
|
||||
const opts = { headers: withMailboxHeaders(mb) };
|
||||
|
||||
// Resolve folder path using the proper folder utilities
|
||||
const endpoint = await resolveFolderPath(accessToken, folder);
|
||||
|
||||
// Add query parameters
|
||||
const queryParams = {
|
||||
$top: count,
|
||||
$orderby: 'receivedDateTime desc',
|
||||
$select: config.EMAIL_SELECT_FIELDS
|
||||
};
|
||||
|
||||
// Add date filtering if specified
|
||||
const dateConditions = buildDateFilter(args.dateFrom, args.dateTo, args.dateRange);
|
||||
if (dateConditions.length > 0) {
|
||||
queryParams.$filter = buildODataFilter(dateConditions);
|
||||
}
|
||||
|
||||
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams, opts);
|
||||
// Make API call
|
||||
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams);
|
||||
|
||||
if (!response.value || response.value.length === 0) {
|
||||
const where = mb.kind === 'user' ? `${folder} (${mb.smtpOrUpn})` : folder;
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `No emails found in ${where}.`
|
||||
text: `No emails found in ${folder}.`
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
// Format results
|
||||
const emailList = response.value.map((email, index) => {
|
||||
const sender = email.from ? email.from.emailAddress : { name: 'Unknown', address: 'unknown' };
|
||||
const date = formatDateTime(email.receivedDateTime);
|
||||
|
|
@ -57,10 +60,8 @@ async function handleListEmails(args) {
|
|||
return `${index + 1}. ${readStatus}${date} - From: ${sender.name} (${sender.address})\nSubject: ${email.subject}\nID: ${email.id}\n${convLine}`;
|
||||
}).join("\n");
|
||||
|
||||
// Build result message with date filter info
|
||||
let resultMessage = `Found ${response.value.length} emails in ${folder}`;
|
||||
if (mb.kind === 'user') {
|
||||
resultMessage += ` [mailbox: ${mb.smtpOrUpn}]`;
|
||||
}
|
||||
|
||||
if (args.dateRange) {
|
||||
resultMessage += ` (${args.dateRange})`;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ const { callGraphAPI } = require('../utils/graph-api');
|
|||
const { ensureAuthenticated } = require('../auth');
|
||||
const { cleanBody } = require('../utils/bodyParser');
|
||||
const { formatDateTime } = require('../utils/time-formatter');
|
||||
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
|
||||
|
||||
/**
|
||||
* Format a single email for display
|
||||
|
|
@ -20,12 +19,14 @@ function formatEmail(email, emailId) {
|
|||
}
|
||||
|
||||
try {
|
||||
// Format sender, recipients, etc.
|
||||
const sender = email.from ? `${email.from.emailAddress.name} (${email.from.emailAddress.address})` : 'Unknown';
|
||||
const to = email.toRecipients ? email.toRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None';
|
||||
const cc = email.ccRecipients && email.ccRecipients.length > 0 ? email.ccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None';
|
||||
const bcc = email.bccRecipients && email.bccRecipients.length > 0 ? email.bccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None';
|
||||
const date = formatDateTime(email.receivedDateTime);
|
||||
|
||||
// Extract and clean body content (cleanBody handles both HTML and plain text)
|
||||
let body = '';
|
||||
if (email.body) {
|
||||
body = cleanBody(email.body.content);
|
||||
|
|
@ -33,6 +34,7 @@ function formatEmail(email, emailId) {
|
|||
body = cleanBody(email.bodyPreview) || 'No content';
|
||||
}
|
||||
|
||||
// Format the email
|
||||
return `From: ${sender}
|
||||
To: ${to}
|
||||
${cc !== 'None' ? `CC: ${cc}\n` : ''}${bcc !== 'None' ? `BCC: ${bcc}\n` : ''}Subject: ${email.subject}
|
||||
|
|
@ -53,8 +55,6 @@ ${body}`;
|
|||
*/
|
||||
async function handleReadMultipleEmails(args) {
|
||||
const emailIds = args.ids;
|
||||
const mb = normalizeMailbox(args.mailbox);
|
||||
const opts = { headers: withMailboxHeaders(mb) };
|
||||
|
||||
if (!emailIds || !Array.isArray(emailIds) || emailIds.length === 0) {
|
||||
return {
|
||||
|
|
@ -65,6 +65,7 @@ async function handleReadMultipleEmails(args) {
|
|||
};
|
||||
}
|
||||
|
||||
// Limit the number of emails to prevent overwhelming responses
|
||||
const maxEmails = 10;
|
||||
if (emailIds.length > maxEmails) {
|
||||
return {
|
||||
|
|
@ -76,16 +77,18 @@ async function handleReadMultipleEmails(args) {
|
|||
}
|
||||
|
||||
try {
|
||||
// Get access token
|
||||
const accessToken = await ensureAuthenticated();
|
||||
|
||||
// Create concurrent API calls for all email IDs
|
||||
const emailPromises = emailIds.map(async (emailId) => {
|
||||
try {
|
||||
const endpoint = buildPath(mb, `messages/${emailId}`);
|
||||
const endpoint = `me/messages/${encodeURIComponent(emailId)}`;
|
||||
const queryParams = {
|
||||
$select: config.EMAIL_DETAIL_FIELDS
|
||||
};
|
||||
|
||||
const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams, opts);
|
||||
const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams);
|
||||
return { emailId, email, error: null };
|
||||
} catch (error) {
|
||||
console.error(`Error reading email ${emailId}: ${error.message}`);
|
||||
|
|
@ -93,10 +96,10 @@ async function handleReadMultipleEmails(args) {
|
|||
}
|
||||
});
|
||||
|
||||
// Wait for all API calls to complete
|
||||
const results = await Promise.all(emailPromises);
|
||||
|
||||
const mailboxNote = mb.kind === 'user' ? ` Mailbox: ${mb.smtpOrUpn}.` : '';
|
||||
|
||||
// Format all emails
|
||||
const formattedEmails = results.map((result, index) => {
|
||||
const emailNumber = index + 1;
|
||||
const separator = "=".repeat(80);
|
||||
|
|
@ -115,10 +118,11 @@ ${formattedEmail}`;
|
|||
}
|
||||
});
|
||||
|
||||
// Count successful vs failed reads
|
||||
const successCount = results.filter(r => !r.error && r.email).length;
|
||||
const errorCount = results.filter(r => r.error || !r.email).length;
|
||||
|
||||
const summary = `Retrieved ${successCount} email(s) successfully${errorCount > 0 ? `, ${errorCount} failed` : ''}.${mailboxNote}
|
||||
const summary = `Retrieved ${successCount} email(s) successfully${errorCount > 0 ? `, ${errorCount} failed` : ''}.
|
||||
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ const { callGraphAPI } = require('../utils/graph-api');
|
|||
const { ensureAuthenticated } = require('../auth');
|
||||
const { cleanBody } = require('../utils/bodyParser');
|
||||
const { formatDateTime } = require('../utils/time-formatter');
|
||||
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
|
||||
|
||||
/**
|
||||
* Read email handler
|
||||
|
|
@ -15,7 +14,6 @@ const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/ma
|
|||
*/
|
||||
async function handleReadEmail(args) {
|
||||
const emailId = args.id;
|
||||
const mb = normalizeMailbox(args.mailbox);
|
||||
|
||||
if (!emailId) {
|
||||
return {
|
||||
|
|
@ -27,17 +25,17 @@ async function handleReadEmail(args) {
|
|||
}
|
||||
|
||||
try {
|
||||
// Get access token
|
||||
const accessToken = await ensureAuthenticated();
|
||||
|
||||
// Do not pre-encode the ID — callGraphAPI encodes each path segment once.
|
||||
const endpoint = buildPath(mb, `messages/${emailId}`);
|
||||
// Make API call to get email details
|
||||
const endpoint = `me/messages/${encodeURIComponent(emailId)}`;
|
||||
const queryParams = {
|
||||
$select: config.EMAIL_DETAIL_FIELDS
|
||||
};
|
||||
const opts = { headers: withMailboxHeaders(mb) };
|
||||
|
||||
try {
|
||||
const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams, opts);
|
||||
const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams);
|
||||
|
||||
if (!email) {
|
||||
return {
|
||||
|
|
@ -50,12 +48,14 @@ async function handleReadEmail(args) {
|
|||
};
|
||||
}
|
||||
|
||||
// Format sender, recipients, etc.
|
||||
const sender = email.from ? `${email.from.emailAddress.name} (${email.from.emailAddress.address})` : 'Unknown';
|
||||
const to = email.toRecipients ? email.toRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None';
|
||||
const cc = email.ccRecipients && email.ccRecipients.length > 0 ? email.ccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None';
|
||||
const bcc = email.bccRecipients && email.bccRecipients.length > 0 ? email.bccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None';
|
||||
const date = formatDateTime(email.receivedDateTime);
|
||||
|
||||
// Extract and clean body content (cleanBody handles both HTML and plain text)
|
||||
let body = '';
|
||||
if (email.body) {
|
||||
body = cleanBody(email.body.content);
|
||||
|
|
@ -63,9 +63,8 @@ async function handleReadEmail(args) {
|
|||
body = cleanBody(email.bodyPreview) || 'No content';
|
||||
}
|
||||
|
||||
const mailboxLine = mb.kind === 'user' ? `Mailbox: ${mb.smtpOrUpn}\n` : '';
|
||||
|
||||
const formattedEmail = `${mailboxLine}From: ${sender}
|
||||
// Format the email
|
||||
const formattedEmail = `From: ${sender}
|
||||
To: ${to}
|
||||
${cc !== 'None' ? `CC: ${cc}\n` : ''}${bcc !== 'None' ? `BCC: ${bcc}\n` : ''}Subject: ${email.subject}
|
||||
Date: ${date}
|
||||
|
|
@ -85,12 +84,13 @@ ${body}`;
|
|||
} catch (error) {
|
||||
console.error(`Error reading email: ${error.message}`);
|
||||
|
||||
// Improved error handling with more specific messages
|
||||
if (error.message.includes("doesn't belong to the targeted mailbox")) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `The email ID seems invalid or doesn't belong to the targeted mailbox${mb.kind === 'user' ? ` (${mb.smtpOrUpn})` : ''}. Pass the same mailbox used when listing/searching, or try a different email ID.`
|
||||
text: `The email ID seems invalid or doesn't belong to your mailbox. Please try with a different email ID.`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ const { ensureAuthenticated } = require('../auth');
|
|||
const { resolveFolderPath } = require('./folder-utils');
|
||||
const { formatDateTime } = require('../utils/time-formatter');
|
||||
const { buildODataFilter, buildDateFilter } = require('../utils/odata-helpers');
|
||||
const { normalizeMailbox, withMailboxHeaders } = require('../utils/mailbox');
|
||||
|
||||
/**
|
||||
* Search emails handler
|
||||
|
|
@ -26,8 +25,6 @@ async function handleSearchEmails(args) {
|
|||
const hasAttachments = args.hasAttachments === true || args.hasAttachments === 'true' ? true : undefined;
|
||||
const unreadOnly = args.unreadOnly === true || args.unreadOnly === 'true' ? true : undefined;
|
||||
const strict = args.strict === true || args.strict === 'true';
|
||||
const mb = normalizeMailbox(args.mailbox);
|
||||
const graphOpts = { headers: withMailboxHeaders(mb) };
|
||||
|
||||
// Date filtering uses the same timezone-aware helpers as list-emails.
|
||||
const dateFrom = args.dateFrom || '';
|
||||
|
|
@ -39,8 +36,8 @@ async function handleSearchEmails(args) {
|
|||
const accessToken = await ensureAuthenticated();
|
||||
|
||||
// Resolve the folder path
|
||||
const endpoint = await resolveFolderPath(accessToken, folder, mb);
|
||||
console.error(`Using endpoint: ${endpoint} for folder: ${folder} mailbox=${mb.graphRoot}`);
|
||||
const endpoint = await resolveFolderPath(accessToken, folder);
|
||||
console.error(`Using endpoint: ${endpoint} for folder: ${folder}`);
|
||||
|
||||
// Execute progressive search
|
||||
const response = await progressiveSearch(
|
||||
|
|
@ -50,11 +47,10 @@ async function handleSearchEmails(args) {
|
|||
{ hasAttachments, unreadOnly },
|
||||
count,
|
||||
strict,
|
||||
{ dateFrom, dateTo, dateRange },
|
||||
graphOpts
|
||||
{ dateFrom, dateTo, dateRange }
|
||||
);
|
||||
|
||||
return formatSearchResults(response, { dateFrom, dateTo, dateRange, mailbox: mb.kind === 'user' ? mb.smtpOrUpn : null });
|
||||
return formatSearchResults(response, { dateFrom, dateTo, dateRange });
|
||||
} catch (error) {
|
||||
// Handle authentication errors
|
||||
if (error.message === 'Authentication required') {
|
||||
|
|
@ -91,7 +87,7 @@ async function handleSearchEmails(args) {
|
|||
* 3. Only boolean filters → $filter + $orderby (fully supported)
|
||||
* 4. Fallback → recent emails (only when not in strict mode; marked clearly)
|
||||
*/
|
||||
async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms, count, strict = false, dateOpts = {}, graphOpts = {}) {
|
||||
async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms, count, strict = false, dateOpts = {}) {
|
||||
const hasTextTerms = !!(searchTerms.query || searchTerms.from || searchTerms.to || searchTerms.subject);
|
||||
const hasBooleanFilters = filterTerms.hasAttachments === true || filterTerms.unreadOnly === true;
|
||||
const hasDateFilters = !!(dateOpts.dateFrom || dateOpts.dateTo || dateOpts.dateRange);
|
||||
|
|
@ -137,7 +133,7 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms
|
|||
};
|
||||
|
||||
console.error(`Attempting combined KQL search: ${kqlQuery}`);
|
||||
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params, graphOpts);
|
||||
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
|
||||
|
||||
if (response.value && response.value.length > 0) {
|
||||
let filtered = applyClientSideFilters(response.value, filterTerms);
|
||||
|
|
@ -166,7 +162,7 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms
|
|||
};
|
||||
|
||||
console.error(`Attempting single-term search (${term}): ${kqlQuery}`);
|
||||
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params, graphOpts);
|
||||
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
|
||||
|
||||
if (response.value && response.value.length > 0) {
|
||||
let filtered = applyClientSideFilters(response.value, filterTerms);
|
||||
|
|
@ -198,7 +194,7 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms
|
|||
};
|
||||
|
||||
console.error(`Attempting filter-only search: ${params.$filter}`);
|
||||
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params, graphOpts);
|
||||
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
|
||||
console.error(`Filter-only search found ${response.value?.length || 0} results`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
|
@ -223,7 +219,7 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms
|
|||
basicParams.$filter = dateFilterString;
|
||||
}
|
||||
|
||||
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, basicParams, graphOpts);
|
||||
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, basicParams);
|
||||
console.error(`Fallback to recent emails found ${response.value?.length || 0} results`);
|
||||
|
||||
if (dateFilterString) {
|
||||
|
|
@ -322,7 +318,6 @@ function formatSearchResults(response, dateOpts = {}) {
|
|||
if (dateOpts.dateRange) dateParts.push(`dateRange: ${dateOpts.dateRange}`);
|
||||
if (dateOpts.dateFrom) dateParts.push(`from: ${dateOpts.dateFrom}`);
|
||||
if (dateOpts.dateTo) dateParts.push(`to: ${dateOpts.dateTo}`);
|
||||
if (dateOpts.mailbox) dateParts.push(`mailbox: ${dateOpts.mailbox}`);
|
||||
const dateInfo = dateParts.length > 0 ? ` (${dateParts.join(', ')})` : '';
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
const config = require('../config');
|
||||
const { callGraphAPI } = require('../utils/graph-api');
|
||||
const { ensureAuthenticated } = require('../auth');
|
||||
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
|
||||
|
||||
/**
|
||||
* Send email handler
|
||||
|
|
@ -12,21 +11,9 @@ const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/ma
|
|||
* @returns {object} - MCP response
|
||||
*/
|
||||
async function handleSendEmail(args) {
|
||||
const {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject,
|
||||
body,
|
||||
importance = 'normal',
|
||||
saveToSentItems = true,
|
||||
from: fromArg,
|
||||
onBehalfOf = false
|
||||
} = args;
|
||||
|
||||
const mb = normalizeMailbox(args.mailbox);
|
||||
const onBehalf = onBehalfOf === true || onBehalfOf === 'true';
|
||||
const { to, cc, bcc, subject, body, importance = 'normal', saveToSentItems = true } = args;
|
||||
|
||||
// Validate required parameters
|
||||
if (!to) {
|
||||
return {
|
||||
content: [{
|
||||
|
|
@ -55,8 +42,10 @@ async function handleSendEmail(args) {
|
|||
}
|
||||
|
||||
try {
|
||||
// Get access token
|
||||
const accessToken = await ensureAuthenticated();
|
||||
|
||||
// Format recipients
|
||||
const toRecipients = to.split(',').map(email => {
|
||||
email = email.trim();
|
||||
return {
|
||||
|
|
@ -84,67 +73,29 @@ async function handleSendEmail(args) {
|
|||
};
|
||||
}) : [];
|
||||
|
||||
const fromAddr = (fromArg && String(fromArg).trim())
|
||||
|| (mb.kind === 'user' ? mb.smtpOrUpn : null);
|
||||
|
||||
// Default: when mailbox is a shared UPN, send via users/{upn}/sendMail (Send As style).
|
||||
// onBehalfOf=true forces me/sendMail with from=shared (Send on Behalf style).
|
||||
let sendPath = 'me/sendMail';
|
||||
let anchorMb = mb;
|
||||
if (mb.kind === 'user' && !onBehalf) {
|
||||
sendPath = buildPath(mb, 'sendMail');
|
||||
} else if (fromAddr && onBehalf) {
|
||||
sendPath = 'me/sendMail';
|
||||
anchorMb = normalizeMailbox(fromAddr);
|
||||
} else if (mb.kind === 'user') {
|
||||
sendPath = buildPath(mb, 'sendMail');
|
||||
}
|
||||
|
||||
const message = {
|
||||
subject,
|
||||
body: {
|
||||
// New Outlook + Graph: prefer HTML when the body looks like a document.
|
||||
// Match <html, <!doctype html, or a leading HTML fragment with common tags.
|
||||
contentType: /<!DOCTYPE\s+html|<html[\s>]|<(?:table|div|h[1-6]|p)\b/i.test(body || '')
|
||||
? 'HTML'
|
||||
: 'Text',
|
||||
content: body
|
||||
},
|
||||
toRecipients,
|
||||
ccRecipients: ccRecipients.length > 0 ? ccRecipients : undefined,
|
||||
bccRecipients: bccRecipients.length > 0 ? bccRecipients : undefined,
|
||||
importance
|
||||
};
|
||||
|
||||
if (fromAddr) {
|
||||
message.from = {
|
||||
emailAddress: {
|
||||
address: fromAddr
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare email object
|
||||
const emailObject = {
|
||||
message,
|
||||
message: {
|
||||
subject,
|
||||
body: {
|
||||
contentType: body.includes('<html') ? 'html' : 'text',
|
||||
content: body
|
||||
},
|
||||
toRecipients,
|
||||
ccRecipients: ccRecipients.length > 0 ? ccRecipients : undefined,
|
||||
bccRecipients: bccRecipients.length > 0 ? bccRecipients : undefined,
|
||||
importance
|
||||
},
|
||||
saveToSentItems
|
||||
};
|
||||
|
||||
await callGraphAPI(
|
||||
accessToken,
|
||||
'POST',
|
||||
sendPath,
|
||||
emailObject,
|
||||
null,
|
||||
{ headers: withMailboxHeaders(anchorMb) }
|
||||
);
|
||||
|
||||
const fromNote = fromAddr ? `\nFrom: ${fromAddr}${onBehalf ? ' (on behalf)' : ''}` : '';
|
||||
const mailboxNote = mb.kind === 'user' ? `\nMailbox: ${mb.smtpOrUpn}` : '';
|
||||
// Make API call to send email
|
||||
await callGraphAPI(accessToken, 'POST', 'me/sendMail', emailObject);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Email sent successfully!${mailboxNote}${fromNote}\n\nSubject: ${subject}\nRecipients: ${toRecipients.length}${ccRecipients.length > 0 ? ` + ${ccRecipients.length} CC` : ''}${bccRecipients.length > 0 ? ` + ${bccRecipients.length} BCC` : ''}\nMessage Length: ${body.length} characters`
|
||||
text: `Email sent successfully!\n\nSubject: ${subject}\nRecipients: ${toRecipients.length}${ccRecipients.length > 0 ? ` + ${ccRecipients.length} CC` : ''}${bccRecipients.length > 0 ? ` + ${bccRecipients.length} BCC` : ''}\nMessage Length: ${body.length} characters`
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
const { callGraphAPI } = require('../utils/graph-api');
|
||||
const { ensureAuthenticated } = require('../auth');
|
||||
const { getFolderIdByName } = require('../email/folder-utils');
|
||||
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
|
||||
|
||||
/**
|
||||
* Create folder handler
|
||||
|
|
@ -14,7 +13,6 @@ const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/ma
|
|||
async function handleCreateFolder(args) {
|
||||
const folderName = args.name;
|
||||
const parentFolder = args.parentFolder || '';
|
||||
const mb = normalizeMailbox(args.mailbox);
|
||||
|
||||
if (!folderName) {
|
||||
return {
|
||||
|
|
@ -26,8 +24,11 @@ async function handleCreateFolder(args) {
|
|||
}
|
||||
|
||||
try {
|
||||
// Get access token
|
||||
const accessToken = await ensureAuthenticated();
|
||||
const result = await createMailFolder(accessToken, folderName, parentFolder, mb);
|
||||
|
||||
// Create folder with appropriate parent
|
||||
const result = await createMailFolder(accessToken, folderName, parentFolder);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
|
|
@ -56,21 +57,26 @@ async function handleCreateFolder(args) {
|
|||
|
||||
/**
|
||||
* Create a new mail folder
|
||||
* @param {string} accessToken - Access token
|
||||
* @param {string} folderName - Name of the folder to create
|
||||
* @param {string} parentFolderName - Name of the parent folder (optional)
|
||||
* @returns {Promise<object>} - Result object with status and message
|
||||
*/
|
||||
async function createMailFolder(accessToken, folderName, parentFolderName, mb) {
|
||||
const opts = { headers: withMailboxHeaders(mb) };
|
||||
async function createMailFolder(accessToken, folderName, parentFolderName) {
|
||||
try {
|
||||
const existingFolder = await getFolderIdByName(accessToken, folderName, mb);
|
||||
// Check if a folder with this name already exists
|
||||
const existingFolder = await getFolderIdByName(accessToken, folderName);
|
||||
if (existingFolder) {
|
||||
return {
|
||||
success: false,
|
||||
message: `A folder named "${folderName}" already exists${mb.kind === 'user' ? ` in ${mb.smtpOrUpn}` : ''}.`
|
||||
message: `A folder named "${folderName}" already exists.`
|
||||
};
|
||||
}
|
||||
|
||||
let endpoint = buildPath(mb, 'mailFolders');
|
||||
// If parent folder specified, find its ID
|
||||
let endpoint = 'me/mailFolders';
|
||||
if (parentFolderName) {
|
||||
const parentId = await getFolderIdByName(accessToken, parentFolderName, mb);
|
||||
const parentId = await getFolderIdByName(accessToken, parentFolderName);
|
||||
if (!parentId) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -78,9 +84,10 @@ async function createMailFolder(accessToken, folderName, parentFolderName, mb) {
|
|||
};
|
||||
}
|
||||
|
||||
endpoint = buildPath(mb, `mailFolders/${parentId}/childFolders`);
|
||||
endpoint = `me/mailFolders/${parentId}/childFolders`;
|
||||
}
|
||||
|
||||
// Create the folder
|
||||
const folderData = {
|
||||
displayName: folderName
|
||||
};
|
||||
|
|
@ -89,20 +96,17 @@ async function createMailFolder(accessToken, folderName, parentFolderName, mb) {
|
|||
accessToken,
|
||||
'POST',
|
||||
endpoint,
|
||||
folderData,
|
||||
null,
|
||||
opts
|
||||
folderData
|
||||
);
|
||||
|
||||
if (response && response.id) {
|
||||
const locationInfo = parentFolderName
|
||||
? `inside "${parentFolderName}"`
|
||||
: "at the root level";
|
||||
const mailboxInfo = mb.kind === 'user' ? ` (mailbox: ${mb.smtpOrUpn})` : '';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully created folder "${folderName}" ${locationInfo}${mailboxInfo}.`,
|
||||
message: `Successfully created folder "${folderName}" ${locationInfo}.`,
|
||||
folderId: response.id
|
||||
};
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -5,16 +5,11 @@ const { handleListFolders } = require('./list');
|
|||
const handleCreateFolder = require('./create');
|
||||
const handleMoveEmails = require('./move');
|
||||
|
||||
const mailboxProp = {
|
||||
type: "string",
|
||||
description: "Optional shared/delegated mailbox UPN or SMTP. Omit for the signed-in user's primary mailbox."
|
||||
};
|
||||
|
||||
// Folder management tool definitions
|
||||
const folderTools = [
|
||||
{
|
||||
name: "list-folders",
|
||||
description: "Lists mail folders in your Outlook account (or a shared mailbox when mailbox is set)",
|
||||
description: "Lists mail folders in your Outlook account",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -25,8 +20,7 @@ const folderTools = [
|
|||
includeChildren: {
|
||||
anyOf: [{ type: "boolean" }, { type: "string" }],
|
||||
description: "Include child folders in hierarchy"
|
||||
},
|
||||
mailbox: mailboxProp
|
||||
}
|
||||
},
|
||||
required: []
|
||||
},
|
||||
|
|
@ -34,7 +28,7 @@ const folderTools = [
|
|||
},
|
||||
{
|
||||
name: "create-folder",
|
||||
description: "Creates a new mail folder. There is no delete-folder tool — avoid test folders on shared mailboxes.",
|
||||
description: "Creates a new mail folder",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -45,8 +39,7 @@ const folderTools = [
|
|||
parentFolder: {
|
||||
type: "string",
|
||||
description: "Optional parent folder name (default is root)"
|
||||
},
|
||||
mailbox: mailboxProp
|
||||
}
|
||||
},
|
||||
required: ["name"]
|
||||
},
|
||||
|
|
@ -54,7 +47,7 @@ const folderTools = [
|
|||
},
|
||||
{
|
||||
name: "move-emails",
|
||||
description: "Moves emails from one folder to another within the same mailbox",
|
||||
description: "Moves emails from one folder to another",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -69,8 +62,7 @@ const folderTools = [
|
|||
sourceFolder: {
|
||||
type: "string",
|
||||
description: "Optional name of the source folder (default is inbox)"
|
||||
},
|
||||
mailbox: mailboxProp
|
||||
}
|
||||
},
|
||||
required: ["emailIds", "targetFolder"]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
*/
|
||||
const { callGraphAPI } = require('../utils/graph-api');
|
||||
const { ensureAuthenticated } = require('../auth');
|
||||
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
|
||||
|
||||
/**
|
||||
* List folders handler
|
||||
|
|
@ -13,23 +12,20 @@ const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/ma
|
|||
async function handleListFolders(args) {
|
||||
const includeItemCounts = args.includeItemCounts === true || args.includeItemCounts === 'true';
|
||||
const includeChildren = args.includeChildren === true || args.includeChildren === 'true';
|
||||
const mb = normalizeMailbox(args.mailbox);
|
||||
|
||||
try {
|
||||
// Get access token
|
||||
const accessToken = await ensureAuthenticated();
|
||||
|
||||
// Get all mail folders
|
||||
const folders = await getAllFoldersHierarchy(accessToken, includeItemCounts, mb);
|
||||
|
||||
const mailboxPrefix = mb.kind === 'user' ? `Mailbox: ${mb.smtpOrUpn}\n\n` : '';
|
||||
const folders = await getAllFoldersHierarchy(accessToken, includeItemCounts);
|
||||
|
||||
// If including children, format as hierarchy
|
||||
if (includeChildren) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: mailboxPrefix + formatFolderHierarchy(folders, includeItemCounts)
|
||||
text: formatFolderHierarchy(folders, includeItemCounts)
|
||||
}]
|
||||
};
|
||||
} else {
|
||||
|
|
@ -37,7 +33,7 @@ async function handleListFolders(args) {
|
|||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: mailboxPrefix + formatFolderList(folders, includeItemCounts)
|
||||
text: formatFolderList(folders, includeItemCounts)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
|
@ -64,12 +60,10 @@ async function handleListFolders(args) {
|
|||
* Get all mail folders with hierarchy information
|
||||
* @param {string} accessToken - Access token
|
||||
* @param {boolean} includeItemCounts - Include item counts in response
|
||||
* @param {object} mb - normalizeMailbox result
|
||||
* @returns {Promise<Array>} - Array of folder objects with hierarchy
|
||||
*/
|
||||
async function getAllFoldersHierarchy(accessToken, includeItemCounts, mb = normalizeMailbox(null)) {
|
||||
async function getAllFoldersHierarchy(accessToken, includeItemCounts) {
|
||||
try {
|
||||
const opts = { headers: withMailboxHeaders(mb) };
|
||||
// Determine select fields based on whether to include counts
|
||||
const selectFields = includeItemCounts
|
||||
? 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount'
|
||||
|
|
@ -79,13 +73,12 @@ async function getAllFoldersHierarchy(accessToken, includeItemCounts, mb = norma
|
|||
const response = await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
buildPath(mb, 'mailFolders'),
|
||||
'me/mailFolders',
|
||||
null,
|
||||
{
|
||||
$top: 100,
|
||||
$select: selectFields
|
||||
},
|
||||
opts
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.value) {
|
||||
|
|
@ -100,10 +93,9 @@ async function getAllFoldersHierarchy(accessToken, includeItemCounts, mb = norma
|
|||
const childResponse = await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
buildPath(mb, `mailFolders/${folder.id}/childFolders`),
|
||||
`me/mailFolders/${folder.id}/childFolders`,
|
||||
null,
|
||||
{ $select: selectFields },
|
||||
opts
|
||||
{ $select: selectFields }
|
||||
);
|
||||
|
||||
// Add parent folder info to each child
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
const { callGraphAPI } = require('../utils/graph-api');
|
||||
const { ensureAuthenticated } = require('../auth');
|
||||
const { getFolderIdByName } = require('../email/folder-utils');
|
||||
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
|
||||
|
||||
/**
|
||||
* Move emails handler
|
||||
|
|
@ -15,7 +14,6 @@ async function handleMoveEmails(args) {
|
|||
const emailIds = args.emailIds || '';
|
||||
const targetFolder = args.targetFolder || '';
|
||||
const sourceFolder = args.sourceFolder || '';
|
||||
const mb = normalizeMailbox(args.mailbox);
|
||||
|
||||
if (!emailIds) {
|
||||
return {
|
||||
|
|
@ -36,8 +34,10 @@ async function handleMoveEmails(args) {
|
|||
}
|
||||
|
||||
try {
|
||||
// Get access token
|
||||
const accessToken = await ensureAuthenticated();
|
||||
|
||||
// Parse email IDs
|
||||
const ids = emailIds.split(',').map(id => id.trim()).filter(id => id);
|
||||
|
||||
if (ids.length === 0) {
|
||||
|
|
@ -49,7 +49,8 @@ async function handleMoveEmails(args) {
|
|||
};
|
||||
}
|
||||
|
||||
const result = await moveEmailsToFolder(accessToken, ids, targetFolder, sourceFolder, mb);
|
||||
// Move emails
|
||||
const result = await moveEmailsToFolder(accessToken, ids, targetFolder, sourceFolder);
|
||||
|
||||
return {
|
||||
content: [{
|
||||
|
|
@ -78,34 +79,40 @@ async function handleMoveEmails(args) {
|
|||
|
||||
/**
|
||||
* Move emails to a folder
|
||||
* @param {string} accessToken - Access token
|
||||
* @param {Array<string>} emailIds - Array of email IDs to move
|
||||
* @param {string} targetFolderName - Name of the target folder
|
||||
* @param {string} sourceFolderName - Name of the source folder (optional)
|
||||
* @returns {Promise<object>} - Result object with status and message
|
||||
*/
|
||||
async function moveEmailsToFolder(accessToken, emailIds, targetFolderName, sourceFolderName, mb) {
|
||||
const opts = { headers: withMailboxHeaders(mb) };
|
||||
async function moveEmailsToFolder(accessToken, emailIds, targetFolderName, sourceFolderName) {
|
||||
try {
|
||||
const targetFolderId = await getFolderIdByName(accessToken, targetFolderName, mb);
|
||||
// Get the target folder ID
|
||||
const targetFolderId = await getFolderIdByName(accessToken, targetFolderName);
|
||||
if (!targetFolderId) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Target folder "${targetFolderName}" not found${mb.kind === 'user' ? ` in ${mb.smtpOrUpn}` : ''}. Please specify a valid folder name.`
|
||||
message: `Target folder "${targetFolderName}" not found. Please specify a valid folder name.`
|
||||
};
|
||||
}
|
||||
|
||||
// Track successful and failed moves
|
||||
const results = {
|
||||
successful: [],
|
||||
failed: []
|
||||
};
|
||||
|
||||
// Process each email one by one to handle errors independently
|
||||
for (const emailId of emailIds) {
|
||||
try {
|
||||
// Move the email
|
||||
await callGraphAPI(
|
||||
accessToken,
|
||||
'POST',
|
||||
buildPath(mb, `messages/${emailId}/move`),
|
||||
`me/messages/${emailId}/move`,
|
||||
{
|
||||
destinationId: targetFolderId
|
||||
},
|
||||
null,
|
||||
opts
|
||||
}
|
||||
);
|
||||
|
||||
results.successful.push(emailId);
|
||||
|
|
@ -118,24 +125,25 @@ async function moveEmailsToFolder(accessToken, emailIds, targetFolderName, sourc
|
|||
}
|
||||
}
|
||||
|
||||
// Generate result message
|
||||
let message = '';
|
||||
|
||||
if (results.successful.length > 0) {
|
||||
message += `Successfully moved ${results.successful.length} email(s) to "${targetFolderName}"`;
|
||||
if (mb.kind === 'user') message += ` (${mb.smtpOrUpn})`;
|
||||
message += '.';
|
||||
message += `Successfully moved ${results.successful.length} email(s) to "${targetFolderName}".`;
|
||||
}
|
||||
|
||||
if (results.failed.length > 0) {
|
||||
if (message) message += '\n\n';
|
||||
message += `Failed to move ${results.failed.length} email(s). Errors:`;
|
||||
|
||||
// Show first few errors with details
|
||||
const maxErrors = Math.min(results.failed.length, 3);
|
||||
for (let i = 0; i < maxErrors; i++) {
|
||||
const failure = results.failed[i];
|
||||
message += `\n- Email ${i + 1}: ${failure.error}`;
|
||||
message += `\n- Email ${i+1}: ${failure.error}`;
|
||||
}
|
||||
|
||||
// If there are more errors, just mention the count
|
||||
if (results.failed.length > maxErrors) {
|
||||
message += `\n...and ${results.failed.length - maxErrors} more.`;
|
||||
}
|
||||
|
|
|
|||
33
index.js
33
index.js
|
|
@ -8,47 +8,43 @@
|
|||
* INSTRUCTIONS FOR AI MODELS:
|
||||
* This server provides comprehensive Outlook integration with the following capabilities:
|
||||
*
|
||||
* AUTHENTICATION (Required First):
|
||||
* - Use `check-auth-status()` to verify authentication (also reports shared-mailbox scopes)
|
||||
* 🔐 AUTHENTICATION (Required First):
|
||||
* - Use `check-auth-status()` to verify authentication
|
||||
* - Use `authenticate()` if not authenticated (follow the provided URL)
|
||||
*
|
||||
* EMAIL MANAGEMENT:
|
||||
* 📧 EMAIL MANAGEMENT:
|
||||
* - `list-emails()` - List emails with advanced date filtering. Results include conversationId.
|
||||
* - `search-emails({ from, subject, query, unreadOnly, hasAttachments })` - Search emails. Results include conversationId.
|
||||
* - `read-email({ id })` - Read full email content (body auto-cleaned)
|
||||
* - `read-emails({ ids: [id1, id2] })` - Read multiple emails at once (max: 10, bodies auto-cleaned)
|
||||
* - `get-email-thread({ conversationId })` - Fetch a complete deduplicated thread (quoted replies stripped).
|
||||
* - `get-email-thread({ conversationId })` - Fetch a complete deduplicated thread (quoted replies stripped). Use conversationId from list-emails or search-emails. Fallback: pass ids array of specific message IDs.
|
||||
* - `send-email({ to, subject, body })` - Send new emails
|
||||
* - Optional `mailbox` on email/folder/thread tools targets a shared/delegated mailbox (UPN/SMTP)
|
||||
* - `list-mailboxes()` - Probe primary + seeded shared mailboxes for read access
|
||||
*
|
||||
* RECOMMENDED EMAIL WORKFLOW:
|
||||
* 💡 RECOMMENDED EMAIL WORKFLOW:
|
||||
* 1. search-emails() or list-emails() → get conversationId from results
|
||||
* 2. get-email-thread({ conversationId }) → read the full thread efficiently
|
||||
* 3. For shared mailboxes: list-mailboxes() or pass mailbox: 'shared@domain' on every call (IDs are mailbox-scoped)
|
||||
*
|
||||
* CALENDAR MANAGEMENT:
|
||||
* 📅 CALENDAR MANAGEMENT:
|
||||
* - `list-events()` - List calendar events
|
||||
* - `create-event({ subject, start, end })` - Create meetings
|
||||
* - `decline-event()`, `cancel-event()` - Respond to invitations
|
||||
*
|
||||
* FOLDER MANAGEMENT:
|
||||
* 📁 FOLDER MANAGEMENT:
|
||||
* - `list-folders()` - List mail folders
|
||||
* - `create-folder({ name })` - Create new folders
|
||||
* - `move-emails({ emailIds, targetFolder })` - Organize emails
|
||||
*
|
||||
* EMAIL RULES:
|
||||
* 📋 EMAIL RULES:
|
||||
* - `list-rules()` - List inbox rules
|
||||
* - `create-rule({ name, conditions, actions })` - Automate email handling
|
||||
*
|
||||
* KEY FEATURES:
|
||||
* 💡 KEY FEATURES:
|
||||
* - Date filtering: Use dateRange ("today", "last7days") or dateFrom/dateTo
|
||||
* - High limits: Up to 500 emails/events (WARNING: may consume significant tokens)
|
||||
* - Comprehensive search: Filter by sender, subject, attachments, read status
|
||||
* - Full automation: Create rules for automatic email organization
|
||||
* - Shared mailboxes: Mail.Read.Shared / Mail.ReadWrite.Shared / Mail.Send.Shared + re-auth after upgrade
|
||||
*
|
||||
* For complete documentation, see README.md
|
||||
* 📖 For complete documentation, see MCP_TOOLS_GUIDE.md
|
||||
*/
|
||||
const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
|
||||
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
||||
|
|
@ -59,12 +55,10 @@ const { emailTools } = require('./email');
|
|||
const { folderTools } = require('./folder');
|
||||
const { rulesTools } = require('./rules');
|
||||
const { threadTool } = require('./tools/get-email-thread');
|
||||
const { mailboxTools } = require('./mailbox');
|
||||
|
||||
// Log startup information
|
||||
console.error(`STARTING ${config.SERVER_NAME.toUpperCase()} MCP SERVER v${config.SERVER_VERSION}`);
|
||||
console.error(`STARTING ${config.SERVER_NAME.toUpperCase()} MCP SERVER`);
|
||||
console.error(`Test mode is ${config.USE_TEST_MODE ? 'enabled' : 'disabled'}`);
|
||||
console.error(`Shared mailboxes: ${config.ENABLE_SHARED_MAILBOXES ? 'enabled' : 'disabled'}`);
|
||||
if (config.DEBUG_MODE) {
|
||||
console.error(`[DEBUG] Current Working Directory: ${process.cwd()}`);
|
||||
console.error(`[DEBUG] MS_CLIENT_ID: ${process.env.MS_CLIENT_ID ? 'SET' : 'NOT SET'}`);
|
||||
|
|
@ -78,8 +72,7 @@ const TOOLS = [
|
|||
...emailTools,
|
||||
...folderTools,
|
||||
...rulesTools,
|
||||
threadTool,
|
||||
...(config.ENABLE_SHARED_MAILBOXES ? mailboxTools : [])
|
||||
threadTool
|
||||
];
|
||||
|
||||
// Create server with tools capabilities
|
||||
|
|
@ -115,7 +108,7 @@ server.fallbackRequestHandler = async (request) => {
|
|||
serverInfo: {
|
||||
name: config.SERVER_NAME,
|
||||
version: config.SERVER_VERSION,
|
||||
description: "Comprehensive Outlook integration with email, calendar, folders, rules, and shared mailboxes. See README.md for complete documentation."
|
||||
description: "Comprehensive Outlook integration with email, calendar, folders, and rules management. See MCP_TOOLS_GUIDE.md for complete documentation."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
/**
|
||||
* Mailbox discovery module
|
||||
*/
|
||||
const { listMailboxesTool, handleListMailboxes } = require('./list');
|
||||
|
||||
const mailboxTools = [listMailboxesTool];
|
||||
|
||||
module.exports = {
|
||||
mailboxTools,
|
||||
handleListMailboxes
|
||||
};
|
||||
359
mailbox/list.js
359
mailbox/list.js
|
|
@ -1,359 +0,0 @@
|
|||
/**
|
||||
* list-mailboxes — discover primary + candidate shared mailboxes and probe access.
|
||||
*
|
||||
* Graph cannot enumerate "mailboxes I have rights to". Candidates come from:
|
||||
* - primary (always)
|
||||
* - OUTLOOK_SHARED_MAILBOXES env
|
||||
* - local probe cache
|
||||
* - explicit candidates[] argument
|
||||
*
|
||||
* Optional directorySearch is intentionally not implemented in v1 (needs User.ReadBasic.All
|
||||
* and still does not prove mailbox rights).
|
||||
*/
|
||||
const config = require('../config');
|
||||
const { callGraphAPI } = require('../utils/graph-api');
|
||||
const { ensureAuthenticated } = require('../auth');
|
||||
const {
|
||||
normalizeMailbox,
|
||||
buildPath,
|
||||
withMailboxHeaders,
|
||||
parseSharedMailboxEnv,
|
||||
loadMailboxCache,
|
||||
saveMailboxCache
|
||||
} = require('../utils/mailbox');
|
||||
|
||||
/**
|
||||
* Run async work over items with a concurrency limit.
|
||||
*/
|
||||
async function mapPool(items, concurrency, fn) {
|
||||
const results = new Array(items.length);
|
||||
let next = 0;
|
||||
async function worker() {
|
||||
while (next < items.length) {
|
||||
const i = next++;
|
||||
results[i] = await fn(items[i], i);
|
||||
}
|
||||
}
|
||||
const n = Math.max(1, Math.min(concurrency || 4, items.length || 1));
|
||||
await Promise.all(Array.from({ length: n }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a single mailbox (or primary).
|
||||
*/
|
||||
async function probeOne(accessToken, address, sources) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (!address || address === 'me' || address === 'primary') {
|
||||
try {
|
||||
const me = await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
'me',
|
||||
null,
|
||||
{ $select: 'displayName,mail,userPrincipalName' }
|
||||
);
|
||||
const smtp = me.mail || me.userPrincipalName || 'me';
|
||||
// Confirm mail read on primary
|
||||
let read = false;
|
||||
try {
|
||||
await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
'me/mailFolders/inbox',
|
||||
null,
|
||||
{ $select: 'id,displayName' }
|
||||
);
|
||||
read = true;
|
||||
} catch {
|
||||
read = false;
|
||||
}
|
||||
return {
|
||||
address: smtp,
|
||||
displayName: me.displayName || smtp,
|
||||
isPrimary: true,
|
||||
capabilities: {
|
||||
read: read,
|
||||
listFolders: read,
|
||||
sendAs: 'self'
|
||||
},
|
||||
source: ['me'],
|
||||
lastProbedAt: now,
|
||||
error: read ? null : 'Could not read primary inbox'
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
address: 'me',
|
||||
displayName: 'primary',
|
||||
isPrimary: true,
|
||||
capabilities: { read: false, listFolders: false, sendAs: 'self' },
|
||||
source: ['me'],
|
||||
lastProbedAt: now,
|
||||
error: e.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const mb = normalizeMailbox(address);
|
||||
const opts = { headers: withMailboxHeaders(mb) };
|
||||
const result = {
|
||||
address: mb.smtpOrUpn,
|
||||
displayName: null,
|
||||
isPrimary: false,
|
||||
capabilities: {
|
||||
read: false,
|
||||
listFolders: false,
|
||||
sendAs: 'unverified'
|
||||
},
|
||||
source: sources || ['probe'],
|
||||
lastProbedAt: now,
|
||||
error: null
|
||||
};
|
||||
|
||||
// Best-effort identity resolve (may 403 without directory scopes — ignore)
|
||||
try {
|
||||
const user = await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
buildPath(mb, ''),
|
||||
null,
|
||||
{ $select: 'displayName,mail,userPrincipalName' },
|
||||
opts
|
||||
);
|
||||
// buildPath(mb,'') returns users/upn — GET users/{upn} works
|
||||
result.displayName = user.displayName || null;
|
||||
if (user.mail) result.address = user.mail;
|
||||
else if (user.userPrincipalName) result.address = user.userPrincipalName;
|
||||
} catch (e) {
|
||||
// fall through — mailbox may still be readable without user profile
|
||||
console.error(`[list-mailboxes] profile resolve ${mb.smtpOrUpn}: ${e.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
buildPath(mb, 'mailFolders/inbox'),
|
||||
null,
|
||||
{ $select: 'id,displayName,totalItemCount' },
|
||||
opts
|
||||
);
|
||||
result.capabilities.read = true;
|
||||
} catch (e) {
|
||||
result.capabilities.read = false;
|
||||
result.error = e.message;
|
||||
}
|
||||
|
||||
try {
|
||||
await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
buildPath(mb, 'mailFolders'),
|
||||
null,
|
||||
{ $top: 1, $select: 'id,displayName' },
|
||||
opts
|
||||
);
|
||||
result.capabilities.listFolders = true;
|
||||
} catch {
|
||||
result.capabilities.listFolders = false;
|
||||
}
|
||||
|
||||
// sendAs cannot be proven without a send or draft; leave unverified when readable
|
||||
if (!result.capabilities.read) {
|
||||
result.capabilities.sendAs = 'denied_or_unknown';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatResults(rows, primaryLabel) {
|
||||
if (!rows.length) {
|
||||
return 'No mailboxes to report.';
|
||||
}
|
||||
|
||||
const lines = [`Mailboxes for ${primaryLabel}`, ''];
|
||||
rows.forEach((r, i) => {
|
||||
const tags = [];
|
||||
if (r.isPrimary) tags.push('PRIMARY');
|
||||
if (r.capabilities.read) tags.push('read=yes');
|
||||
else tags.push('read=no');
|
||||
if (r.capabilities.listFolders) tags.push('folders=yes');
|
||||
else tags.push('folders=no');
|
||||
tags.push(`sendAs=${r.capabilities.sendAs}`);
|
||||
|
||||
lines.push(`${i + 1}. ${r.address}${r.displayName ? ` (${r.displayName})` : ''}`);
|
||||
lines.push(` ${tags.join(' ')}`);
|
||||
if (r.source && r.source.length) {
|
||||
lines.push(` source: ${r.source.join('+')}`);
|
||||
}
|
||||
if (r.error && !r.capabilities.read) {
|
||||
lines.push(` error: ${r.error}`);
|
||||
}
|
||||
if (!r.isPrimary && r.capabilities.read) {
|
||||
lines.push(' note: Send As not probed; use send-email({ mailbox }) to verify');
|
||||
}
|
||||
lines.push('');
|
||||
});
|
||||
|
||||
lines.push('Notes:');
|
||||
lines.push('- Graph cannot list all mailboxes you have rights to. Seed with OUTLOOK_SHARED_MAILBOXES or candidates[].');
|
||||
lines.push('- Message IDs are mailbox-scoped: pass the same mailbox on list/search/read/thread/send.');
|
||||
lines.push('- Exchange Full Access / Send As are separate from Graph app scopes (Mail.*.Shared).');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function handleListMailboxes(args = {}) {
|
||||
if (!config.ENABLE_SHARED_MAILBOXES) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Shared mailbox support is disabled (OUTLOOK_ENABLE_SHARED_MAILBOXES=false). Only the primary mailbox is available via tools without a mailbox parameter.'
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
const includeCached = args.includeCached !== false && args.includeCached !== 'false';
|
||||
const includeConfigured = args.includeConfigured !== false && args.includeConfigured !== 'false';
|
||||
const refresh = args.refresh === true || args.refresh === 'true';
|
||||
|
||||
let candidates = [];
|
||||
// Always probe primary
|
||||
candidates.push({ address: 'me', sources: ['me'] });
|
||||
|
||||
if (includeConfigured) {
|
||||
for (const addr of parseSharedMailboxEnv()) {
|
||||
candidates.push({ address: addr, sources: ['env'] });
|
||||
}
|
||||
}
|
||||
|
||||
if (includeCached) {
|
||||
const cache = loadMailboxCache();
|
||||
for (const m of cache.mailboxes) {
|
||||
if (m.address) candidates.push({ address: m.address, sources: ['cache'] });
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(args.candidates)) {
|
||||
for (const c of args.candidates) {
|
||||
if (c && String(c).trim()) {
|
||||
candidates.push({ address: String(c).trim(), sources: ['candidates'] });
|
||||
}
|
||||
}
|
||||
} else if (typeof args.candidates === 'string' && args.candidates.trim()) {
|
||||
for (const c of args.candidates.split(/[,;\s]+/).filter(Boolean)) {
|
||||
candidates.push({ address: c, sources: ['candidates'] });
|
||||
}
|
||||
}
|
||||
|
||||
// Dedupe by lowercased key; merge sources
|
||||
const map = new Map();
|
||||
for (const c of candidates) {
|
||||
const key = c.address === 'me' || c.address === 'primary'
|
||||
? 'me'
|
||||
: String(c.address).trim().toLowerCase();
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { address: c.address === 'me' ? 'me' : c.address, sources: new Set(c.sources) });
|
||||
} else {
|
||||
for (const s of c.sources) map.get(key).sources.add(s);
|
||||
}
|
||||
}
|
||||
|
||||
// If not refreshing, still probe — cache is only for candidate discovery, not skipping probes.
|
||||
// (refresh reserved for future TTL skip; always probe for accurate rights.)
|
||||
void refresh;
|
||||
|
||||
try {
|
||||
const accessToken = await ensureAuthenticated();
|
||||
const list = Array.from(map.values()).map(v => ({
|
||||
address: v.address,
|
||||
sources: Array.from(v.sources)
|
||||
}));
|
||||
|
||||
const probed = await mapPool(
|
||||
list,
|
||||
config.MAILBOX_PROBE_CONCURRENCY || 4,
|
||||
(item) => probeOne(accessToken, item.address, item.sources)
|
||||
);
|
||||
|
||||
// Persist non-primary results
|
||||
const toCache = probed.filter(p => !p.isPrimary);
|
||||
if (toCache.length) {
|
||||
saveMailboxCache(toCache.map(p => ({
|
||||
address: p.address,
|
||||
displayName: p.displayName,
|
||||
capabilities: p.capabilities,
|
||||
source: p.source,
|
||||
lastProbedAt: p.lastProbedAt,
|
||||
error: p.error
|
||||
})));
|
||||
}
|
||||
|
||||
// Sort: primary first, then readable, then alpha
|
||||
probed.sort((a, b) => {
|
||||
if (a.isPrimary && !b.isPrimary) return -1;
|
||||
if (!a.isPrimary && b.isPrimary) return 1;
|
||||
if (a.capabilities.read !== b.capabilities.read) return a.capabilities.read ? -1 : 1;
|
||||
return String(a.address).localeCompare(String(b.address));
|
||||
});
|
||||
|
||||
const primary = probed.find(p => p.isPrimary);
|
||||
const primaryLabel = primary ? primary.address : 'signed-in user';
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: formatResults(probed, primaryLabel) }]
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.message === 'Authentication required') {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: "Authentication required. Please use the 'authenticate' tool first."
|
||||
}]
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Error listing mailboxes: ${error.message}`
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const listMailboxesTool = {
|
||||
name: 'list-mailboxes',
|
||||
description: 'List the primary mailbox plus candidate shared/delegated mailboxes and probe read/folder access. Candidates come from OUTLOOK_SHARED_MAILBOXES, a local cache, and optional candidates[]. Graph cannot enumerate all mailboxes you have rights to — seed the list. sendAs is unverified until you successfully send.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
candidates: {
|
||||
anyOf: [
|
||||
{ type: 'array', items: { type: 'string' } },
|
||||
{ type: 'string' }
|
||||
],
|
||||
description: 'Extra mailbox UPNs/SMTPs to probe (array or comma-separated string)'
|
||||
},
|
||||
includeCached: {
|
||||
anyOf: [{ type: 'boolean' }, { type: 'string' }],
|
||||
description: 'Include addresses from the local mailbox cache (default true)'
|
||||
},
|
||||
includeConfigured: {
|
||||
anyOf: [{ type: 'boolean' }, { type: 'string' }],
|
||||
description: 'Include OUTLOOK_SHARED_MAILBOXES env seeds (default true)'
|
||||
},
|
||||
refresh: {
|
||||
anyOf: [{ type: 'boolean' }, { type: 'string' }],
|
||||
description: 'Reserved; probes always run in v1.1.0'
|
||||
}
|
||||
},
|
||||
required: []
|
||||
},
|
||||
handler: handleListMailboxes
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
listMailboxesTool,
|
||||
handleListMailboxes,
|
||||
probeOne
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "outlook-mcp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.0.1",
|
||||
"description": "MCP server for Claude to access Outlook data via Microsoft Graph API",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
/**
|
||||
* Unit tests for utils/mailbox.js
|
||||
* Run: node tests/mailbox-path.test.js
|
||||
*/
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
normalizeMailbox,
|
||||
buildPath,
|
||||
withMailboxHeaders,
|
||||
parseSharedMailboxEnv,
|
||||
loadMailboxCache,
|
||||
saveMailboxCache,
|
||||
encodeGraphPath,
|
||||
decodeJwtPayload,
|
||||
analyzeTokenScopes
|
||||
} = require('../utils/mailbox');
|
||||
|
||||
// --- normalizeMailbox ---
|
||||
assert.deepStrictEqual(normalizeMailbox(undefined).kind, 'me');
|
||||
assert.deepStrictEqual(normalizeMailbox(null).graphRoot, 'me');
|
||||
assert.deepStrictEqual(normalizeMailbox('').graphRoot, 'me');
|
||||
assert.deepStrictEqual(normalizeMailbox('me').graphRoot, 'me');
|
||||
assert.deepStrictEqual(normalizeMailbox('PRIMARY').graphRoot, 'me');
|
||||
|
||||
const shared = normalizeMailbox('HelpDesk@Contoso.com');
|
||||
assert.strictEqual(shared.kind, 'user');
|
||||
assert.strictEqual(shared.smtpOrUpn, 'HelpDesk@Contoso.com');
|
||||
assert.strictEqual(shared.key, 'helpdesk@contoso.com');
|
||||
assert.strictEqual(shared.graphRoot, 'users/HelpDesk@Contoso.com');
|
||||
|
||||
const mailto = normalizeMailbox('mailto:it@springfieldy.org');
|
||||
assert.strictEqual(mailto.smtpOrUpn, 'it@springfieldy.org');
|
||||
assert.strictEqual(mailto.graphRoot, 'users/it@springfieldy.org');
|
||||
|
||||
const angled = normalizeMailbox('<desk@example.com>');
|
||||
assert.strictEqual(angled.smtpOrUpn, 'desk@example.com');
|
||||
console.log('✅ normalizeMailbox');
|
||||
|
||||
// --- buildPath ---
|
||||
assert.strictEqual(buildPath(null, 'messages'), 'me/messages');
|
||||
assert.strictEqual(buildPath('me', 'mailFolders/inbox/messages'), 'me/mailFolders/inbox/messages');
|
||||
assert.strictEqual(
|
||||
buildPath('helpdesk@contoso.com', 'messages'),
|
||||
'users/helpdesk@contoso.com/messages'
|
||||
);
|
||||
assert.strictEqual(
|
||||
buildPath('helpdesk@contoso.com', '/mailFolders/sentItems/messages'),
|
||||
'users/helpdesk@contoso.com/mailFolders/sentItems/messages'
|
||||
);
|
||||
assert.strictEqual(buildPath(shared, 'sendMail'), 'users/HelpDesk@Contoso.com/sendMail');
|
||||
console.log('✅ buildPath');
|
||||
|
||||
// --- encode once (same as callGraphAPI) ---
|
||||
const encoded = encodeGraphPath('users/helpdesk@contoso.com/messages');
|
||||
assert.strictEqual(encoded, 'users/helpdesk%40contoso.com/messages');
|
||||
assert.ok(!encoded.includes('%2540'), 'must not double-encode @');
|
||||
console.log('✅ encodeGraphPath single-encode');
|
||||
|
||||
// --- headers ---
|
||||
assert.deepStrictEqual(withMailboxHeaders(null), {});
|
||||
assert.deepStrictEqual(withMailboxHeaders('me'), {});
|
||||
assert.deepStrictEqual(withMailboxHeaders('it@x.org'), { 'X-AnchorMailbox': 'it@x.org' });
|
||||
console.log('✅ withMailboxHeaders');
|
||||
|
||||
// --- env parse ---
|
||||
assert.deepStrictEqual(parseSharedMailboxEnv(''), []);
|
||||
assert.deepStrictEqual(parseSharedMailboxEnv(undefined), []);
|
||||
assert.deepStrictEqual(
|
||||
parseSharedMailboxEnv('a@x.com, b@y.com;c@z.com d@w.com'),
|
||||
['a@x.com', 'b@y.com', 'c@z.com', 'd@w.com']
|
||||
);
|
||||
console.log('✅ parseSharedMailboxEnv');
|
||||
|
||||
// --- cache round-trip ---
|
||||
const tmp = path.join(os.tmpdir(), `outlook-mcp-mb-cache-${Date.now()}.json`);
|
||||
try {
|
||||
const empty = loadMailboxCache(tmp);
|
||||
assert.strictEqual(empty.mailboxes.length, 0);
|
||||
|
||||
saveMailboxCache([
|
||||
{
|
||||
address: 'helpdesk@contoso.com',
|
||||
displayName: 'Help Desk',
|
||||
capabilities: { read: true, listFolders: true, sendAs: 'unverified' },
|
||||
source: ['env', 'probe']
|
||||
}
|
||||
], tmp);
|
||||
|
||||
const loaded = loadMailboxCache(tmp);
|
||||
assert.strictEqual(loaded.mailboxes.length, 1);
|
||||
assert.strictEqual(loaded.mailboxes[0].address, 'helpdesk@contoso.com');
|
||||
assert.ok(loaded.mailboxes[0].source.includes('env'));
|
||||
assert.ok(loaded.mailboxes[0].lastProbedAt);
|
||||
|
||||
// upsert merges sources
|
||||
saveMailboxCache([
|
||||
{
|
||||
address: 'helpdesk@contoso.com',
|
||||
capabilities: { read: true, listFolders: true, sendAs: 'unverified' },
|
||||
source: ['candidates']
|
||||
}
|
||||
], tmp);
|
||||
const merged = loadMailboxCache(tmp);
|
||||
assert.ok(merged.mailboxes[0].source.includes('candidates'));
|
||||
assert.ok(merged.mailboxes[0].source.includes('env'));
|
||||
console.log('✅ mailbox cache round-trip');
|
||||
} finally {
|
||||
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- JWT decode / scope analysis ---
|
||||
function makeJwt(payload) {
|
||||
const h = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
|
||||
const p = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
return `${h}.${p}.sig`;
|
||||
}
|
||||
const tok = makeJwt({ scp: 'Mail.Read Mail.Read.Shared User.Read offline_access' });
|
||||
const analyzed = analyzeTokenScopes(tok);
|
||||
assert.ok(analyzed.present.includes('Mail.Read.Shared'));
|
||||
assert.ok(analyzed.missing.includes('Mail.Send.Shared'));
|
||||
assert.ok(analyzed.missing.includes('Mail.ReadWrite.Shared'));
|
||||
assert.strictEqual(decodeJwtPayload('not-a-jwt'), null);
|
||||
console.log('✅ analyzeTokenScopes / decodeJwtPayload');
|
||||
|
||||
console.log('\nAll mailbox-path tests passed.');
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
/**
|
||||
* Unit tests for shared send path selection (pure logic mirror).
|
||||
* Run: node tests/mailbox-send-path.test.js
|
||||
*/
|
||||
const assert = require('assert');
|
||||
const { normalizeMailbox, buildPath } = require('../utils/mailbox');
|
||||
|
||||
function resolveSendPath(args) {
|
||||
const mb = normalizeMailbox(args.mailbox);
|
||||
const onBehalf = args.onBehalfOf === true || args.onBehalfOf === 'true';
|
||||
if (mb.kind === 'user' && !onBehalf) {
|
||||
return buildPath(mb, 'sendMail');
|
||||
}
|
||||
return 'me/sendMail';
|
||||
}
|
||||
|
||||
assert.strictEqual(resolveSendPath({}), 'me/sendMail');
|
||||
assert.strictEqual(resolveSendPath({ mailbox: 'me' }), 'me/sendMail');
|
||||
assert.strictEqual(
|
||||
resolveSendPath({ mailbox: 'helpdesk@contoso.com' }),
|
||||
'users/helpdesk@contoso.com/sendMail'
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolveSendPath({ mailbox: 'helpdesk@contoso.com', onBehalfOf: true }),
|
||||
'me/sendMail'
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolveSendPath({ mailbox: 'helpdesk@contoso.com', onBehalfOf: 'true' }),
|
||||
'me/sendMail'
|
||||
);
|
||||
|
||||
console.log('✅ send path selection');
|
||||
console.log('\nAll mailbox-send-path tests passed.');
|
||||
|
|
@ -13,18 +13,16 @@ const config = require('../config');
|
|||
const { callGraphAPI } = require('../utils/graph-api');
|
||||
const { ensureAuthenticated } = require('../auth');
|
||||
const { buildThread } = require('../utils/threadBuilder');
|
||||
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
|
||||
|
||||
const MAX_MESSAGES = 20;
|
||||
|
||||
/**
|
||||
* Fetch all messages in a conversation across ALL folders (inbox + sent + etc.)
|
||||
* using the conversationId filter on the mailbox messages endpoint.
|
||||
* using the conversationId filter on the global me/messages endpoint.
|
||||
*/
|
||||
async function fetchByConversationId(accessToken, conversationId, mb) {
|
||||
async function fetchByConversationId(accessToken, conversationId) {
|
||||
const allMessages = [];
|
||||
const opts = { headers: withMailboxHeaders(mb) };
|
||||
let url = buildPath(mb, 'messages');
|
||||
let url = 'me/messages';
|
||||
let params = {
|
||||
$filter: `conversationId eq '${conversationId}'`,
|
||||
$select: config.EMAIL_DETAIL_FIELDS,
|
||||
|
|
@ -34,10 +32,13 @@ async function fetchByConversationId(accessToken, conversationId, mb) {
|
|||
// buildThread() handles chronological sorting in memory instead.
|
||||
};
|
||||
|
||||
// Single page is enough for most threads; avoid following absolute nextLink URLs
|
||||
// through callGraphAPI (path encoder is relative-path only).
|
||||
const page = await callGraphAPI(accessToken, 'GET', url, null, params, opts);
|
||||
if (page.value) allMessages.push(...page.value);
|
||||
// Page through results (unlikely to exceed one page for most threads, but safe)
|
||||
while (url) {
|
||||
const page = await callGraphAPI(accessToken, 'GET', url, null, params);
|
||||
if (page.value) allMessages.push(...page.value);
|
||||
url = page['@odata.nextLink'] || null;
|
||||
params = null; // params are embedded in nextLink on subsequent pages
|
||||
}
|
||||
|
||||
return allMessages;
|
||||
}
|
||||
|
|
@ -48,12 +49,9 @@ async function fetchByConversationId(accessToken, conversationId, mb) {
|
|||
* @param {string[]} [args.ids] - Explicit message IDs to include
|
||||
* @param {string} [args.conversationId] - Fetch entire conversation from all folders
|
||||
* @param {string} [args.subject] - Optional subject label for the thread header
|
||||
* @param {string} [args.mailbox] - Optional shared mailbox UPN/SMTP
|
||||
*/
|
||||
async function handleGetEmailThread(args) {
|
||||
const { ids, conversationId, subject } = args || {};
|
||||
const mb = normalizeMailbox(args && args.mailbox);
|
||||
const opts = { headers: withMailboxHeaders(mb) };
|
||||
|
||||
const hasIds = Array.isArray(ids) && ids.length > 0;
|
||||
const hasConvId = typeof conversationId === 'string' && conversationId.trim().length > 0;
|
||||
|
|
@ -83,8 +81,9 @@ async function handleGetEmailThread(args) {
|
|||
let failCount = 0;
|
||||
|
||||
if (hasConvId) {
|
||||
// Auto-fetch entire conversation from all folders (inbox + sent + etc.)
|
||||
try {
|
||||
messages = await fetchByConversationId(accessToken, conversationId.trim(), mb);
|
||||
messages = await fetchByConversationId(accessToken, conversationId.trim());
|
||||
} catch (err) {
|
||||
console.error(`[get-email-thread] conversationId fetch failed: ${err.message}`);
|
||||
return {
|
||||
|
|
@ -92,19 +91,13 @@ async function handleGetEmailThread(args) {
|
|||
};
|
||||
}
|
||||
|
||||
// If caller also passed explicit IDs, merge in any that weren't in the conversation result
|
||||
if (hasIds) {
|
||||
const fetchedIds = new Set(messages.map(m => m.id));
|
||||
const extras = await Promise.all(
|
||||
ids.filter(id => !fetchedIds.has(id)).map(async (id) => {
|
||||
try {
|
||||
return await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
buildPath(mb, `messages/${id}`),
|
||||
null,
|
||||
{ $select: config.EMAIL_DETAIL_FIELDS },
|
||||
opts
|
||||
);
|
||||
return await callGraphAPI(accessToken, 'GET', `me/messages/${encodeURIComponent(id)}`, null, { $select: config.EMAIL_DETAIL_FIELDS });
|
||||
} catch (err) {
|
||||
console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`);
|
||||
failCount++;
|
||||
|
|
@ -115,16 +108,10 @@ async function handleGetEmailThread(args) {
|
|||
messages.push(...extras.filter(Boolean));
|
||||
}
|
||||
} else {
|
||||
// IDs-only path — fetch concurrently, same as before
|
||||
const results = await Promise.all(ids.map(async (id) => {
|
||||
try {
|
||||
const message = await callGraphAPI(
|
||||
accessToken,
|
||||
'GET',
|
||||
buildPath(mb, `messages/${id}`),
|
||||
null,
|
||||
{ $select: config.EMAIL_DETAIL_FIELDS },
|
||||
opts
|
||||
);
|
||||
const message = await callGraphAPI(accessToken, 'GET', `me/messages/${encodeURIComponent(id)}`, null, { $select: config.EMAIL_DETAIL_FIELDS });
|
||||
return { message, error: null };
|
||||
} catch (err) {
|
||||
console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`);
|
||||
|
|
@ -137,22 +124,21 @@ async function handleGetEmailThread(args) {
|
|||
|
||||
if (messages.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Could not retrieve any messages. Check IDs/conversationId, mailbox, and authentication.' }]
|
||||
content: [{ type: 'text', text: 'Could not retrieve any messages. Check IDs/conversationId and authentication.' }]
|
||||
};
|
||||
}
|
||||
|
||||
const thread = buildThread(messages, subject);
|
||||
const mbNote = mb.kind === 'user' ? `\n\n(Mailbox: ${mb.smtpOrUpn})` : '';
|
||||
const note = failCount > 0 ? `\n\n(Note: ${failCount} message(s) could not be fetched and are excluded.)` : '';
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: thread + mbNote + note }]
|
||||
content: [{ type: 'text', text: thread + note }]
|
||||
};
|
||||
}
|
||||
|
||||
const threadTool = {
|
||||
name: 'get-email-thread',
|
||||
description: 'Fetch a complete email thread and return it as a single clean deduplicated conversation, sorted chronologically. Each message shows only its unique new content — quoted prior replies are stripped, and signatures are deduplicated per sender. Prefer conversationId (from list-emails or search-emails results) to automatically include both inbox AND sent items in the thread. Fall back to ids when you only have specific message IDs without a conversationId. Pass mailbox when the conversation is in a shared mailbox.',
|
||||
description: 'Fetch a complete email thread and return it as a single clean deduplicated conversation, sorted chronologically. Each message shows only its unique new content — quoted prior replies are stripped, and signatures are deduplicated per sender. Prefer conversationId (from list-emails or search-emails results) to automatically include both inbox AND sent items in the thread. Fall back to ids when you only have specific message IDs without a conversationId.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -169,10 +155,6 @@ const threadTool = {
|
|||
subject: {
|
||||
type: 'string',
|
||||
description: 'Optional: override the thread subject shown in the header'
|
||||
},
|
||||
mailbox: {
|
||||
type: 'string',
|
||||
description: "Optional shared/delegated mailbox UPN or SMTP. Omit for primary mailbox. Must match the mailbox the conversationId/IDs came from."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -38,11 +38,9 @@ function getRetryDelay(attempt, retryAfterSeconds) {
|
|||
* @param {string} path - API endpoint path
|
||||
* @param {object} data - Data to send for POST/PUT requests
|
||||
* @param {object} queryParams - Query parameters
|
||||
* @param {object} [options] - Extra options
|
||||
* @param {object} [options.headers] - Additional HTTP headers (e.g. X-AnchorMailbox)
|
||||
* @returns {Promise<object>} - The API response
|
||||
*/
|
||||
async function callGraphAPI(accessToken, method, path, data = null, queryParams = {}, options = {}) {
|
||||
async function callGraphAPI(accessToken, method, path, data = null, queryParams = {}) {
|
||||
// For test tokens, we'll simulate the API call
|
||||
if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) {
|
||||
console.error(`TEST MODE: Simulating ${method} ${path} API call`);
|
||||
|
|
@ -52,25 +50,23 @@ async function callGraphAPI(accessToken, method, path, data = null, queryParams
|
|||
try {
|
||||
console.error(`Making real API call: ${method} ${path}`);
|
||||
|
||||
// Encode path segments properly (do NOT pre-encode UPNs before calling this)
|
||||
// Encode path segments properly
|
||||
const encodedPath = path.split('/')
|
||||
.map(segment => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
|
||||
// Build query string from parameters with special handling for OData filters
|
||||
let queryString = '';
|
||||
if (queryParams && Object.keys(queryParams).length > 0) {
|
||||
// Copy so we do not mutate the caller's object when deleting $filter
|
||||
const qp = { ...queryParams };
|
||||
if (Object.keys(queryParams).length > 0) {
|
||||
// Handle $filter parameter specially to ensure proper URI encoding
|
||||
const filter = qp.$filter;
|
||||
const filter = queryParams.$filter;
|
||||
if (filter) {
|
||||
delete qp.$filter; // Remove from regular params
|
||||
delete queryParams.$filter; // Remove from regular params
|
||||
}
|
||||
|
||||
// Build query string with proper encoding for regular params
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(qp)) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
params.append(key, value);
|
||||
}
|
||||
|
||||
|
|
@ -96,10 +92,9 @@ async function callGraphAPI(accessToken, method, path, data = null, queryParams
|
|||
console.error(`Full URL: ${url}`);
|
||||
|
||||
const maxAttempts = Math.max(1, config.MAX_RETRIES + 1);
|
||||
const extraHeaders = (options && options.headers) || {};
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const result = await makeSingleRequest(url, optionsForRequest(method, accessToken, extraHeaders), data);
|
||||
const result = await makeSingleRequest(url, optionsForRequest(method, accessToken), data);
|
||||
|
||||
if (result.success) {
|
||||
return result.body;
|
||||
|
|
@ -132,17 +127,13 @@ async function callGraphAPI(accessToken, method, path, data = null, queryParams
|
|||
|
||||
/**
|
||||
* Build the https request options object.
|
||||
* @param {string} method
|
||||
* @param {string} accessToken
|
||||
* @param {object} [extraHeaders]
|
||||
*/
|
||||
function optionsForRequest(method, accessToken, extraHeaders = {}) {
|
||||
function optionsForRequest(method, accessToken) {
|
||||
return {
|
||||
method: method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...extraHeaders
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
211
utils/mailbox.js
211
utils/mailbox.js
|
|
@ -1,211 +0,0 @@
|
|||
/**
|
||||
* Shared / delegated mailbox routing helpers.
|
||||
*
|
||||
* Graph has no API that lists "mailboxes this user can access".
|
||||
* We rewrite me/... -> users/{upn}/... when a mailbox is supplied,
|
||||
* and support candidate+probe discovery with a local cache + env seeds.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('../config');
|
||||
|
||||
/**
|
||||
* Normalize a mailbox argument into a routing context.
|
||||
* @param {string|null|undefined} mailbox - UPN or SMTP, or empty for primary
|
||||
* @returns {{ kind: 'me'|'user', key: string, smtpOrUpn: string|null, graphRoot: string }}
|
||||
*/
|
||||
function normalizeMailbox(mailbox) {
|
||||
if (mailbox == null) {
|
||||
return { kind: 'me', key: 'me', smtpOrUpn: null, graphRoot: 'me' };
|
||||
}
|
||||
const trimmed = String(mailbox).trim();
|
||||
if (!trimmed || trimmed.toLowerCase() === 'me' || trimmed.toLowerCase() === 'primary') {
|
||||
return { kind: 'me', key: 'me', smtpOrUpn: null, graphRoot: 'me' };
|
||||
}
|
||||
// Strip mailto: and angle brackets if pasted from UI
|
||||
let addr = trimmed.replace(/^mailto:/i, '').replace(/^<|>$/g, '').trim();
|
||||
// callGraphAPI encodes each path segment; pass raw UPN as one segment (no pre-encode)
|
||||
return {
|
||||
kind: 'user',
|
||||
key: addr.toLowerCase(),
|
||||
smtpOrUpn: addr,
|
||||
graphRoot: `users/${addr}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Graph path under the mailbox root.
|
||||
* relativePath should not start with "/".
|
||||
* Examples: buildPath(ctx, 'messages'), buildPath(ctx, 'mailFolders/inbox')
|
||||
*/
|
||||
function buildPath(mailboxCtx, relativePath) {
|
||||
const ctx = typeof mailboxCtx === 'string' || mailboxCtx == null
|
||||
? normalizeMailbox(mailboxCtx)
|
||||
: mailboxCtx;
|
||||
const rel = String(relativePath || '').replace(/^\/+/, '');
|
||||
if (!rel) return ctx.graphRoot;
|
||||
return `${ctx.graphRoot}/${rel}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra HTTPS headers for shared-mailbox routing.
|
||||
*/
|
||||
function withMailboxHeaders(mailboxCtx) {
|
||||
const ctx = typeof mailboxCtx === 'string' || mailboxCtx == null
|
||||
? normalizeMailbox(mailboxCtx)
|
||||
: mailboxCtx;
|
||||
if (ctx.kind !== 'user' || !ctx.smtpOrUpn) return {};
|
||||
return { 'X-AnchorMailbox': ctx.smtpOrUpn };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse OUTLOOK_SHARED_MAILBOXES env (comma/semicolon/whitespace separated).
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function parseSharedMailboxEnv(envValue = process.env.OUTLOOK_SHARED_MAILBOXES) {
|
||||
if (!envValue || !String(envValue).trim()) return [];
|
||||
return String(envValue)
|
||||
.split(/[,;\s]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function defaultMailboxCachePath() {
|
||||
if (process.env.OUTLOOK_MAILBOX_CACHE_PATH) {
|
||||
return process.env.OUTLOOK_MAILBOX_CACHE_PATH;
|
||||
}
|
||||
const tokenPath = config.AUTH_CONFIG.tokenStorePath;
|
||||
return `${tokenPath}.mailboxes.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ version: number, updatedAt: string|null, mailboxes: Array<object> }}
|
||||
*/
|
||||
function loadMailboxCache(cachePath = defaultMailboxCachePath()) {
|
||||
try {
|
||||
if (!fs.existsSync(cachePath)) {
|
||||
return { version: 1, updatedAt: null, mailboxes: [] };
|
||||
}
|
||||
const raw = fs.readFileSync(cachePath, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
if (!data || !Array.isArray(data.mailboxes)) {
|
||||
return { version: 1, updatedAt: null, mailboxes: [] };
|
||||
}
|
||||
return {
|
||||
version: data.version || 1,
|
||||
updatedAt: data.updatedAt || null,
|
||||
mailboxes: data.mailboxes
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[mailbox] cache read error:', e.message);
|
||||
return { version: 1, updatedAt: null, mailboxes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert mailbox probe results into the cache file.
|
||||
* @param {Array<object>} entries
|
||||
* @param {string} [cachePath]
|
||||
*/
|
||||
function saveMailboxCache(entries, cachePath = defaultMailboxCachePath()) {
|
||||
const existing = loadMailboxCache(cachePath);
|
||||
const byKey = new Map();
|
||||
for (const m of existing.mailboxes) {
|
||||
const k = (m.address || m.key || '').toLowerCase();
|
||||
if (k) byKey.set(k, m);
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const k = (entry.address || entry.key || '').toLowerCase();
|
||||
if (!k || k === 'me') continue; // do not cache primary as shared
|
||||
const prev = byKey.get(k) || {};
|
||||
const sources = new Set([...(prev.source || []), ...(entry.source || [])]);
|
||||
byKey.set(k, {
|
||||
...prev,
|
||||
...entry,
|
||||
address: entry.address || prev.address || k,
|
||||
source: Array.from(sources),
|
||||
lastProbedAt: entry.lastProbedAt || new Date().toISOString()
|
||||
});
|
||||
}
|
||||
const payload = {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
mailboxes: Array.from(byKey.values())
|
||||
};
|
||||
try {
|
||||
const dir = path.dirname(cachePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(cachePath, JSON.stringify(payload, null, 2));
|
||||
} catch (e) {
|
||||
console.error('[mailbox] cache write error:', e.message);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode path the same way callGraphAPI does (for unit tests).
|
||||
*/
|
||||
function encodeGraphPath(pathStr) {
|
||||
return String(pathStr)
|
||||
.split('/')
|
||||
.map(segment => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT payload (middle segment) without verifying signature.
|
||||
* Used only to surface scp claims in check-auth-status.
|
||||
* @param {string} token
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function decodeJwtPayload(token) {
|
||||
try {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) return null;
|
||||
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
||||
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared scopes expected when OUTLOOK_ENABLE_SHARED_MAILBOXES is on.
|
||||
*/
|
||||
function expectedSharedScopes() {
|
||||
return ['Mail.Read.Shared', 'Mail.ReadWrite.Shared', 'Mail.Send.Shared'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} token
|
||||
* @returns {{ scp: string, present: string[], missing: string[] }}
|
||||
*/
|
||||
function analyzeTokenScopes(token) {
|
||||
const payload = decodeJwtPayload(token) || {};
|
||||
const scp = typeof payload.scp === 'string' ? payload.scp : '';
|
||||
const roles = Array.isArray(payload.roles) ? payload.roles : [];
|
||||
const granted = new Set([
|
||||
...scp.split(/\s+/).filter(Boolean),
|
||||
...roles
|
||||
]);
|
||||
const expected = expectedSharedScopes();
|
||||
const present = expected.filter(s => granted.has(s));
|
||||
const missing = expected.filter(s => !granted.has(s));
|
||||
return { scp, present, missing, allGranted: Array.from(granted).sort() };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeMailbox,
|
||||
buildPath,
|
||||
withMailboxHeaders,
|
||||
parseSharedMailboxEnv,
|
||||
defaultMailboxCachePath,
|
||||
loadMailboxCache,
|
||||
saveMailboxCache,
|
||||
encodeGraphPath,
|
||||
decodeJwtPayload,
|
||||
expectedSharedScopes,
|
||||
analyzeTokenScopes
|
||||
};
|
||||
Loading…
Reference in a new issue