Compare commits

..

No commits in common. "c112c9b330019e1c67463d8c47f2c98616623ad5" and "4d8ea1b3a7c7d054149bcbb7b0cafdd9ddda95aa" have entirely different histories.

26 changed files with 412 additions and 1473 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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
};

View file

@ -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
};

View file

@ -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"]

View file

@ -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})`;

View file

@ -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` : ''}.
`;

View file

@ -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.`
}
]
};

View file

@ -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 {

View file

@ -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 = {
// Prepare email object
const emailObject = {
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',
contentType: body.includes('<html') ? '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
}
};
}
const emailObject = {
message,
},
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) {

View file

@ -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 {

View file

@ -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"]
},

View file

@ -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

View file

@ -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.`;
}

View file

@ -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."
}
};
}

View file

@ -1,11 +0,0 @@
/**
* Mailbox discovery module
*/
const { listMailboxesTool, handleListMailboxes } = require('./list');
const mailboxTools = [listMailboxesTool];
module.exports = {
mailboxTools,
handleListMailboxes
};

View file

@ -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
};

View file

@ -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": {

View file

@ -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.');

View file

@ -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.');

View file

@ -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);
// 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."
}
}
},

View file

@ -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'
}
};
}

View file

@ -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
};