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.
This commit is contained in:
Seton Carmichael 2026-08-24 08:41:05 -04:00
parent 4d8ea1b3a7
commit e70840552d
26 changed files with 1447 additions and 399 deletions

View file

@ -12,7 +12,28 @@ USE_TEST_MODE=false
# Optional: Enable verbose debug logging # Optional: Enable verbose debug logging
DEBUG_MODE=false DEBUG_MODE=false
# Optional: Default timezone for calendar event creation (IANA tz name). # Optional: Default timezone for calendar event creation and display (IANA tz name).
# Examples: 'America/New_York', 'Europe/London', 'Australia/Sydney' # Examples: 'America/New_York', 'Europe/London', 'Australia/Sydney'
# Defaults to Eastern Standard Time if not set. # Defaults to America/New_York if not set.
# MS_TIMEZONE=America/New_York # MS_TIMEZONE=America/New_York
# Optional: shared / delegated mailbox support (default true).
# Set false on personal-account instances if shared scopes are unwanted.
# OUTLOOK_ENABLE_SHARED_MAILBOXES=true
# Optional: comma-separated seed list of shared mailbox UPNs/SMTPs for list-mailboxes
# OUTLOOK_SHARED_MAILBOXES=helpdesk@contoso.com,it@contoso.com
# Optional: override probe cache path (default: ${OUTLOOK_TOKEN_STORE_PATH}.mailboxes.json)
# OUTLOOK_MAILBOX_CACHE_PATH=
# Optional: parallel probe concurrency for list-mailboxes
# OUTLOOK_MAILBOX_PROBE_CONCURRENCY=4
# Optional: multi-instance token cache path
# OUTLOOK_TOKEN_STORE_PATH=
# Optional: Graph throttle retry knobs
# OUTLOOK_MAX_RETRIES=3
# OUTLOOK_BASE_RETRY_DELAY_MS=1000
# OUTLOOK_MAX_RETRY_DELAY_MS=30000

31
CHANGELOG.md Normal file
View file

@ -0,0 +1,31 @@
# 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.

36
COMMIT_NOTE_DRAFT.md Normal file
View file

@ -0,0 +1,36 @@
# 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,6 +7,7 @@ Built for use with AI agents (Claude, Hermes, etc.) that support the MCP standar
## Features ## Features
- **Email**: List, search, read, send, and reconstruct full conversation threads with quoted-reply stripping and signature deduplication - **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 - **Calendar**: List, create, decline, cancel, and delete events
- **Folders**: List (flat or hierarchical), create, and move emails between folders - **Folders**: List (flat or hierarchical), create, and move emails between folders
- **Inbox Rules**: List, create, and reorder execution priority of inbox rules - **Inbox Rules**: List, create, and reorder execution priority of inbox rules
@ -71,15 +72,21 @@ This server uses MSAL **device code flow** with a **public client** app registra
- `Mail.Read` - `Mail.Read`
- `Mail.ReadWrite` - `Mail.ReadWrite`
- `Mail.Send` - `Mail.Send`
- `Mail.Read.Shared` (shared/delegated mailboxes — work/school only)
- `Mail.ReadWrite.Shared`
- `Mail.Send.Shared`
- `User.Read` - `User.Read`
- `Calendars.Read` - `Calendars.Read`
- `Calendars.ReadWrite` - `Calendars.ReadWrite`
- `MailboxSettings.ReadWrite` - `MailboxSettings.ReadWrite`
- `offline_access` - `offline_access`
7. Copy the **Application (client) ID** — that's your `MS_CLIENT_ID` 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`
No client secret is needed for public client apps. 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 ## Configuration
All configuration is via environment variables: All configuration is via environment variables:
@ -91,7 +98,36 @@ All configuration is via environment variables:
| `USE_TEST_MODE` | No | `false` | Use mock data instead of real API calls | | `USE_TEST_MODE` | No | `false` | Use mock data instead of real API calls |
| `DEBUG_MODE` | No | `false` | Verbose logging (MSAL, API calls, working directory) | | `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) | | `OUTLOOK_TOKEN_STORE_PATH` | No | `~/.outlook-mcp-tokens.json` | Token cache file path (override for multi-instance setups) |
| `MS_TIMEZONE` | No | `Eastern Standard Time` | Default timezone for calendar event creation (IANA or Windows timezone name) | | `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`.
## MCP Client Configuration ## MCP Client Configuration

View file

@ -3,12 +3,16 @@
*/ */
const config = require('../config'); const config = require('../config');
const tokenManager = require('./token-manager'); const tokenManager = require('./token-manager');
const { analyzeTokenScopes, expectedSharedScopes } = require('../utils/mailbox');
async function handleAbout() { 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 { return {
content: [{ content: [{
type: "text", type: "text",
text: `Outlook Assistant MCP Server v${config.SERVER_VERSION}\n\nProvides access to Microsoft Outlook email, calendar, and contacts through Microsoft Graph API.` 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}`
}] }]
}; };
} }
@ -48,6 +52,10 @@ async function handleAuthenticate(args) {
console.error(`[authenticate] Device code flow ended: ${err.message}`); 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 { return {
content: [{ content: [{
type: "text", type: "text",
@ -58,8 +66,9 @@ async function handleAuthenticate(args) {
` 2. Enter code: ${userCode}`, ` 2. Enter code: ${userCode}`,
``, ``,
`You have ${minutesRemaining} minutes to complete sign-in.`, `You have ${minutesRemaining} minutes to complete sign-in.`,
`After signing in, call check-auth-status to confirm.` `After signing in, call check-auth-status to confirm.`,
].join('\n') scopeHint
].filter(Boolean).join('\n')
}] }]
}; };
} }
@ -73,7 +82,30 @@ async function handleCheckAuthStatus() {
return { content: [{ type: "text", text: "Not authenticated" }] }; return { content: [{ type: "text", text: "Not authenticated" }] };
} }
console.error('[CHECK-AUTH-STATUS] Valid token acquired'); console.error('[CHECK-AUTH-STATUS] Valid token acquired');
return { content: [{ type: "text", text: "Authenticated and ready" }] };
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') }] };
} catch (e) { } catch (e) {
console.error('[CHECK-AUTH-STATUS] Error:', e.message); console.error('[CHECK-AUTH-STATUS] Error:', e.message);
return { content: [{ type: "text", text: "Not authenticated" }] }; return { content: [{ type: "text", text: "Not authenticated" }] };
@ -89,7 +121,7 @@ const authTools = [
}, },
{ {
name: "authenticate", 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.", 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.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@ -104,7 +136,7 @@ const authTools = [
}, },
{ {
name: "check-auth-status", name: "check-auth-status",
description: "Check the current authentication status with Microsoft Graph API", description: "Check the current authentication status with Microsoft Graph API, including whether shared-mailbox scopes are present on the access token",
inputSchema: { type: "object", properties: {}, required: [] }, inputSchema: { type: "object", properties: {}, required: [] },
handler: handleCheckAuthStatus handler: handleCheckAuthStatus
} }

View file

@ -7,39 +7,59 @@ const os = require('os');
// Ensure we have a home directory path even if process.env.HOME is undefined // 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 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 = { module.exports = {
// Server information // Server information
SERVER_NAME: "outlook-assistant-main", SERVER_NAME: "outlook-assistant-main",
SERVER_VERSION: "1.0.1", SERVER_VERSION: "1.1.0",
// Test mode setting // Test mode setting
USE_TEST_MODE: process.env.USE_TEST_MODE === 'true', USE_TEST_MODE: process.env.USE_TEST_MODE === 'true',
// Debug mode setting // Debug mode setting
DEBUG_MODE: process.env.DEBUG_MODE === 'true', DEBUG_MODE: process.env.DEBUG_MODE === 'true',
// Shared / delegated mailbox feature flag (scopes + list-mailboxes tool)
ENABLE_SHARED_MAILBOXES: enableSharedMailboxes,
// Authentication configuration // Authentication configuration
AUTH_CONFIG: { AUTH_CONFIG: {
clientId: process.env.MS_CLIENT_ID || '', clientId: process.env.MS_CLIENT_ID || '',
// Optional: set MS_CLIENT_SECRET for confidential client app registrations. // Optional: set MS_CLIENT_SECRET for confidential client app registrations.
// Public client apps (Allow public client flows enabled, no secret) leave this blank. // Public client apps (Allow public client flows enabled, no secret) leave this blank.
clientSecret: process.env.MS_CLIENT_SECRET || '', clientSecret: process.env.MS_CLIENT_SECRET || '',
scopes: ['Mail.Read', 'Mail.ReadWrite', 'Mail.Send', 'User.Read', 'Calendars.Read', 'Calendars.ReadWrite', 'MailboxSettings.ReadWrite', 'offline_access'], scopes: [...baseScopes, ...sharedScopes],
tokenStorePath: process.env.OUTLOOK_TOKEN_STORE_PATH || path.join(homeDir, '.outlook-mcp-tokens.json'), 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) // Device code flow: polling interval in seconds (Microsoft returns the recommended interval)
deviceCodePollingInterval: 5 deviceCodePollingInterval: 5
}, },
// Microsoft Graph API // Microsoft Graph API
GRAPH_API_ENDPOINT: 'https://graph.microsoft.com/v1.0/', GRAPH_API_ENDPOINT: 'https://graph.microsoft.com/v1.0/',
// Calendar constants // Calendar constants
CALENDAR_SELECT_FIELDS: 'id,subject,bodyPreview,start,end,location,organizer,attendees,isAllDay,isCancelled,recurrence', CALENDAR_SELECT_FIELDS: 'id,subject,bodyPreview,start,end,location,organizer,attendees,isAllDay,isCancelled,recurrence',
// Email constants // Email constants
EMAIL_SELECT_FIELDS: 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,hasAttachments,importance,isRead,conversationId', EMAIL_SELECT_FIELDS: 'id,subject,from,toRecipients,ccRecipients,receivedDateTime,bodyPreview,hasAttachments,importance,isRead,conversationId',
EMAIL_DETAIL_FIELDS: 'id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,importance,isRead,internetMessageHeaders', EMAIL_DETAIL_FIELDS: 'id,subject,from,toRecipients,ccRecipients,bccRecipients,receivedDateTime,bodyPreview,body,hasAttachments,importance,isRead,internetMessageHeaders',
// Default timezone for calendar event creation and display (IANA tz name, e.g. 'America/New_York'). // Default timezone for calendar event creation and display (IANA tz name, e.g. 'America/New_York').
// Override via MS_TIMEZONE env var. Graph API accepts IANA timezone identifiers. // Override via MS_TIMEZONE env var. Graph API accepts IANA timezone identifiers.
DEFAULT_TIMEZONE: process.env.MS_TIMEZONE || 'America/New_York', DEFAULT_TIMEZONE: process.env.MS_TIMEZONE || 'America/New_York',
@ -50,5 +70,8 @@ module.exports = {
// Honor Retry-After when Graph sends it; otherwise use exponential backoff. // Honor Retry-After when Graph sends it; otherwise use exponential backoff.
MAX_RETRIES: parseInt(process.env.OUTLOOK_MAX_RETRIES, 10) || 3, MAX_RETRIES: parseInt(process.env.OUTLOOK_MAX_RETRIES, 10) || 3,
BASE_RETRY_DELAY_MS: parseInt(process.env.OUTLOOK_BASE_RETRY_DELAY_MS, 10) || 1000, 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 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
}; };

View file

@ -2,6 +2,7 @@
* Email folder utilities * Email folder utilities
*/ */
const { callGraphAPI } = require('../utils/graph-api'); const { callGraphAPI } = require('../utils/graph-api');
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
/** /**
* Cache of folder information to reduce API calls * Cache of folder information to reduce API calls
@ -13,46 +14,54 @@ const folderCache = {};
* Resolve a folder name to its endpoint path * Resolve a folder name to its endpoint path
* @param {string} accessToken - Access token * @param {string} accessToken - Access token
* @param {string} folderName - Folder name to resolve * @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 * @returns {Promise<string>} - Resolved endpoint path
*/ */
async function resolveFolderPath(accessToken, folderName) { async function resolveFolderPath(accessToken, folderName, mailbox = null) {
const mb = typeof mailbox === 'object' && mailbox && mailbox.graphRoot
? mailbox
: normalizeMailbox(mailbox);
const headers = { headers: withMailboxHeaders(mb) };
// Default to inbox if no folder specified // Default to inbox if no folder specified
if (!folderName) { if (!folderName) {
return 'me/messages'; return buildPath(mb, 'messages');
} }
// Handle well-known folder names // Handle well-known folder names (case-insensitive)
const wellKnownFolders = { // Note: 'inbox' uses the messages shortcut; SentItems aliases included for search UX
'inbox': 'me/messages', const wellKnownRelative = {
'drafts': 'me/mailFolders/drafts/messages', 'inbox': 'messages',
'sent': 'me/mailFolders/sentItems/messages', 'drafts': 'mailFolders/drafts/messages',
'deleted': 'me/mailFolders/deletedItems/messages', 'sent': 'mailFolders/sentItems/messages',
'junk': 'me/mailFolders/junkemail/messages', 'sentitems': 'mailFolders/sentItems/messages',
'archive': 'me/mailFolders/archive/messages' 'deleted': 'mailFolders/deletedItems/messages',
'deleteditems': 'mailFolders/deletedItems/messages',
'junk': 'mailFolders/junkemail/messages',
'junkemail': 'mailFolders/junkemail/messages',
'archive': 'mailFolders/archive/messages'
}; };
// Check if it's a well-known folder (case-insensitive)
const lowerFolderName = folderName.toLowerCase(); const lowerFolderName = folderName.toLowerCase();
if (wellKnownFolders[lowerFolderName]) { if (wellKnownRelative[lowerFolderName]) {
console.error(`Using well-known folder path for "${folderName}"`); const p = buildPath(mb, wellKnownRelative[lowerFolderName]);
return wellKnownFolders[lowerFolderName]; console.error(`Using well-known folder path for "${folderName}": ${p}`);
return p;
} }
try { try {
// Try to find the folder by name const folderId = await getFolderIdByName(accessToken, folderName, mb);
const folderId = await getFolderIdByName(accessToken, folderName);
if (folderId) { if (folderId) {
const path = `me/mailFolders/${folderId}/messages`; const p = buildPath(mb, `mailFolders/${folderId}/messages`);
console.error(`Resolved folder "${folderName}" to path: ${path}`); console.error(`Resolved folder "${folderName}" to path: ${p}`);
return path; return p;
} }
// If not found, fall back to inbox
console.error(`Couldn't find folder "${folderName}", falling back to inbox`); console.error(`Couldn't find folder "${folderName}", falling back to inbox`);
return 'me/messages'; return buildPath(mb, 'messages');
} catch (error) { } catch (error) {
console.error(`Error resolving folder "${folderName}": ${error.message}`); console.error(`Error resolving folder "${folderName}": ${error.message}`);
return 'me/messages'; return buildPath(mb, 'messages');
} }
} }
@ -60,47 +69,53 @@ async function resolveFolderPath(accessToken, folderName) {
* Get the ID of a mail folder by its name * Get the ID of a mail folder by its name
* @param {string} accessToken - Access token * @param {string} accessToken - Access token
* @param {string} folderName - Name of the folder to find * @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 * @returns {Promise<string|null>} - Folder ID or null if not found
*/ */
async function getFolderIdByName(accessToken, folderName) { async function getFolderIdByName(accessToken, folderName, mailbox = null) {
const mb = typeof mailbox === 'object' && mailbox && mailbox.graphRoot
? mailbox
: normalizeMailbox(mailbox);
const opts = { headers: withMailboxHeaders(mb) };
try { try {
// First try with exact match filter console.error(`Looking for folder with name "${folderName}" in ${mb.graphRoot}`);
console.error(`Looking for folder with name "${folderName}"`);
const response = await callGraphAPI( const response = await callGraphAPI(
accessToken, accessToken,
'GET', 'GET',
'me/mailFolders', buildPath(mb, 'mailFolders'),
null, null,
{ $filter: `displayName eq '${folderName}'` } { $filter: `displayName eq '${folderName}'` },
opts
); );
if (response.value && response.value.length > 0) { if (response.value && response.value.length > 0) {
console.error(`Found folder "${folderName}" with ID: ${response.value[0].id}`); console.error(`Found folder "${folderName}" with ID: ${response.value[0].id}`);
return response.value[0].id; 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`); console.error(`No exact match found for "${folderName}", trying case-insensitive search`);
const allFoldersResponse = await callGraphAPI( const allFoldersResponse = await callGraphAPI(
accessToken, accessToken,
'GET', 'GET',
'me/mailFolders', buildPath(mb, 'mailFolders'),
null, null,
{ $top: 100 } { $top: 100 },
opts
); );
if (allFoldersResponse.value) { if (allFoldersResponse.value) {
const lowerFolderName = folderName.toLowerCase(); const lowerFolderName = folderName.toLowerCase();
const matchingFolder = allFoldersResponse.value.find( const matchingFolder = allFoldersResponse.value.find(
folder => folder.displayName.toLowerCase() === lowerFolderName folder => folder.displayName.toLowerCase() === lowerFolderName
); );
if (matchingFolder) { if (matchingFolder) {
console.error(`Found case-insensitive match for "${folderName}" with ID: ${matchingFolder.id}`); console.error(`Found case-insensitive match for "${folderName}" with ID: ${matchingFolder.id}`);
return matchingFolder.id; return matchingFolder.id;
} }
} }
console.error(`No folder found matching "${folderName}"`); console.error(`No folder found matching "${folderName}"`);
return null; return null;
} catch (error) { } catch (error) {
@ -112,51 +127,56 @@ async function getFolderIdByName(accessToken, folderName) {
/** /**
* Get all mail folders * Get all mail folders
* @param {string} accessToken - Access token * @param {string} accessToken - Access token
* @param {string|object|null} mailbox
* @returns {Promise<Array>} - Array of folder objects * @returns {Promise<Array>} - Array of folder objects
*/ */
async function getAllFolders(accessToken) { async function getAllFolders(accessToken, mailbox = null) {
const mb = typeof mailbox === 'object' && mailbox && mailbox.graphRoot
? mailbox
: normalizeMailbox(mailbox);
const opts = { headers: withMailboxHeaders(mb) };
try { try {
// Get top-level folders
const response = await callGraphAPI( const response = await callGraphAPI(
accessToken, accessToken,
'GET', 'GET',
'me/mailFolders', buildPath(mb, 'mailFolders'),
null, null,
{ {
$top: 100, $top: 100,
$select: 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount' $select: 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount'
} },
opts
); );
if (!response.value) { if (!response.value) {
return []; return [];
} }
// Get child folders for folders with children
const foldersWithChildren = response.value.filter(f => f.childFolderCount > 0); const foldersWithChildren = response.value.filter(f => f.childFolderCount > 0);
const childFolderPromises = foldersWithChildren.map(async (folder) => { const childFolderPromises = foldersWithChildren.map(async (folder) => {
try { try {
const childResponse = await callGraphAPI( const childResponse = await callGraphAPI(
accessToken, accessToken,
'GET', 'GET',
`me/mailFolders/${folder.id}/childFolders`, buildPath(mb, `mailFolders/${folder.id}/childFolders`),
null, null,
{ {
$select: 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount' $select: 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount'
} },
opts
); );
return childResponse.value || []; return childResponse.value || [];
} catch (error) { } catch (error) {
console.error(`Error getting child folders for "${folder.displayName}": ${error.message}`); console.error(`Error getting child folders for "${folder.displayName}": ${error.message}`);
return []; return [];
} }
}); });
const childFolders = await Promise.all(childFolderPromises); const childFolders = await Promise.all(childFolderPromises);
// Combine top-level folders and all child folders
return [...response.value, ...childFolders.flat()]; return [...response.value, ...childFolders.flat()];
} catch (error) { } catch (error) {
console.error(`Error getting all folders: ${error.message}`); console.error(`Error getting all folders: ${error.message}`);
@ -167,5 +187,6 @@ async function getAllFolders(accessToken) {
module.exports = { module.exports = {
resolveFolderPath, resolveFolderPath,
getFolderIdByName, getFolderIdByName,
getAllFolders getAllFolders,
folderCache
}; };

View file

@ -7,11 +7,16 @@ const handleReadEmail = require('./read');
const handleReadMultipleEmails = require('./read-multiple'); const handleReadMultipleEmails = require('./read-multiple');
const handleSendEmail = require('./send'); 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 // Email tool definitions
const emailTools = [ const emailTools = [
{ {
name: "list-emails", 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.", 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.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@ -34,7 +39,8 @@ const emailTools = [
dateRange: { dateRange: {
type: "string", type: "string",
description: "Predefined date range ('today', 'yesterday', 'last7days', 'last30days', 'thisweek', 'lastweek', 'thismonth', 'lastmonth')" description: "Predefined date range ('today', 'yesterday', 'last7days', 'last30days', 'thisweek', 'lastweek', 'thismonth', 'lastmonth')"
} },
mailbox: mailboxProp
}, },
required: [] required: []
}, },
@ -42,7 +48,7 @@ const emailTools = [
}, },
{ {
name: "search-emails", 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. 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. Optional mailbox targets a shared mailbox. If no matching emails are found, use 'list-emails' to browse recent mail instead.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@ -93,7 +99,8 @@ const emailTools = [
strict: { strict: {
anyOf: [{ type: "boolean" }, { type: "string" }], anyOf: [{ type: "boolean" }, { type: "string" }],
description: "Set true to disable the fallback to recent emails when no exact search matches are found" description: "Set true to disable the fallback to recent emails when no exact search matches are found"
} },
mailbox: mailboxProp
}, },
required: [] required: []
}, },
@ -101,14 +108,15 @@ const emailTools = [
}, },
{ {
name: "read-email", name: "read-email",
description: "Reads the content of a specific email", description: "Reads the content of a specific email. If the message came from a shared mailbox, pass the same mailbox value.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
id: { id: {
type: "string", type: "string",
description: "ID of the email to read" description: "ID of the email to read"
} },
mailbox: mailboxProp
}, },
required: ["id"] required: ["id"]
}, },
@ -116,7 +124,7 @@ const emailTools = [
}, },
{ {
name: "read-emails", name: "read-emails",
description: "Reads the content of multiple emails at once", description: "Reads the content of multiple emails at once. If IDs came from a shared mailbox, pass the same mailbox value.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@ -126,7 +134,8 @@ const emailTools = [
type: "string" type: "string"
}, },
description: "Array of email IDs to read (max: 10)" description: "Array of email IDs to read (max: 10)"
} },
mailbox: mailboxProp
}, },
required: ["ids"] required: ["ids"]
}, },
@ -134,7 +143,7 @@ const emailTools = [
}, },
{ {
name: "send-email", name: "send-email",
description: "Composes and sends a new 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.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@ -166,6 +175,15 @@ const emailTools = [
saveToSentItems: { saveToSentItems: {
type: "boolean", type: "boolean",
description: "Whether to save the email to sent items" 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"] required: ["to", "subject", "body"]

View file

@ -8,6 +8,7 @@ const { callGraphAPI } = require('../utils/graph-api');
const { ensureAuthenticated } = require('../auth'); const { ensureAuthenticated } = require('../auth');
const { buildODataFilter, buildDateFilter } = require('../utils/odata-helpers'); const { buildODataFilter, buildDateFilter } = require('../utils/odata-helpers');
const { resolveFolderPath } = require('./folder-utils'); const { resolveFolderPath } = require('./folder-utils');
const { normalizeMailbox, withMailboxHeaders } = require('../utils/mailbox');
/** /**
* List emails handler * List emails handler
@ -17,40 +18,36 @@ const { resolveFolderPath } = require('./folder-utils');
async function handleListEmails(args) { async function handleListEmails(args) {
const folder = args.folder || "inbox"; const folder = args.folder || "inbox";
const count = Math.min(parseInt(args.count, 10) || 10, config.MAX_RESULT_COUNT); const count = Math.min(parseInt(args.count, 10) || 10, config.MAX_RESULT_COUNT);
const mb = normalizeMailbox(args.mailbox);
try { try {
// Get access token
const accessToken = await ensureAuthenticated(); const accessToken = await ensureAuthenticated();
const endpoint = await resolveFolderPath(accessToken, folder, mb);
// Resolve folder path using the proper folder utilities const opts = { headers: withMailboxHeaders(mb) };
const endpoint = await resolveFolderPath(accessToken, folder);
// Add query parameters
const queryParams = { const queryParams = {
$top: count, $top: count,
$orderby: 'receivedDateTime desc', $orderby: 'receivedDateTime desc',
$select: config.EMAIL_SELECT_FIELDS $select: config.EMAIL_SELECT_FIELDS
}; };
// Add date filtering if specified
const dateConditions = buildDateFilter(args.dateFrom, args.dateTo, args.dateRange); const dateConditions = buildDateFilter(args.dateFrom, args.dateTo, args.dateRange);
if (dateConditions.length > 0) { if (dateConditions.length > 0) {
queryParams.$filter = buildODataFilter(dateConditions); queryParams.$filter = buildODataFilter(dateConditions);
} }
// Make API call const response = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams, opts);
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams);
if (!response.value || response.value.length === 0) { if (!response.value || response.value.length === 0) {
const where = mb.kind === 'user' ? `${folder} (${mb.smtpOrUpn})` : folder;
return { return {
content: [{ content: [{
type: "text", type: "text",
text: `No emails found in ${folder}.` text: `No emails found in ${where}.`
}] }]
}; };
} }
// Format results
const emailList = response.value.map((email, index) => { const emailList = response.value.map((email, index) => {
const sender = email.from ? email.from.emailAddress : { name: 'Unknown', address: 'unknown' }; const sender = email.from ? email.from.emailAddress : { name: 'Unknown', address: 'unknown' };
const date = formatDateTime(email.receivedDateTime); const date = formatDateTime(email.receivedDateTime);
@ -59,10 +56,12 @@ async function handleListEmails(args) {
return `${index + 1}. ${readStatus}${date} - From: ${sender.name} (${sender.address})\nSubject: ${email.subject}\nID: ${email.id}\n${convLine}`; return `${index + 1}. ${readStatus}${date} - From: ${sender.name} (${sender.address})\nSubject: ${email.subject}\nID: ${email.id}\n${convLine}`;
}).join("\n"); }).join("\n");
// Build result message with date filter info
let resultMessage = `Found ${response.value.length} emails in ${folder}`; let resultMessage = `Found ${response.value.length} emails in ${folder}`;
if (mb.kind === 'user') {
resultMessage += ` [mailbox: ${mb.smtpOrUpn}]`;
}
if (args.dateRange) { if (args.dateRange) {
resultMessage += ` (${args.dateRange})`; resultMessage += ` (${args.dateRange})`;
} else if (args.dateFrom || args.dateTo) { } else if (args.dateFrom || args.dateTo) {
@ -71,9 +70,9 @@ async function handleListEmails(args) {
if (args.dateTo) dateInfo.push(`to: ${args.dateTo}`); if (args.dateTo) dateInfo.push(`to: ${args.dateTo}`);
resultMessage += ` (${dateInfo.join(', ')})`; resultMessage += ` (${dateInfo.join(', ')})`;
} }
resultMessage += `:\n\n${emailList}`; resultMessage += `:\n\n${emailList}`;
return { return {
content: [{ content: [{
type: "text", type: "text",
@ -83,16 +82,16 @@ async function handleListEmails(args) {
} catch (error) { } catch (error) {
if (error.message === 'Authentication required') { if (error.message === 'Authentication required') {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Authentication required. Please use the 'authenticate' tool first." text: "Authentication required. Please use the 'authenticate' tool first."
}] }]
}; };
} }
return { return {
content: [{ content: [{
type: "text", type: "text",
text: `Error listing emails: ${error.message}` text: `Error listing emails: ${error.message}`
}] }]
}; };

View file

@ -6,6 +6,7 @@ const { callGraphAPI } = require('../utils/graph-api');
const { ensureAuthenticated } = require('../auth'); const { ensureAuthenticated } = require('../auth');
const { cleanBody } = require('../utils/bodyParser'); const { cleanBody } = require('../utils/bodyParser');
const { formatDateTime } = require('../utils/time-formatter'); const { formatDateTime } = require('../utils/time-formatter');
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
/** /**
* Format a single email for display * Format a single email for display
@ -19,22 +20,19 @@ function formatEmail(email, emailId) {
} }
try { try {
// Format sender, recipients, etc.
const sender = email.from ? `${email.from.emailAddress.name} (${email.from.emailAddress.address})` : 'Unknown'; 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 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 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 bcc = email.bccRecipients && email.bccRecipients.length > 0 ? email.bccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None';
const date = formatDateTime(email.receivedDateTime); const date = formatDateTime(email.receivedDateTime);
// Extract and clean body content (cleanBody handles both HTML and plain text)
let body = ''; let body = '';
if (email.body) { if (email.body) {
body = cleanBody(email.body.content); body = cleanBody(email.body.content);
} else { } else {
body = cleanBody(email.bodyPreview) || 'No content'; body = cleanBody(email.bodyPreview) || 'No content';
} }
// Format the email
return `From: ${sender} return `From: ${sender}
To: ${to} To: ${to}
${cc !== 'None' ? `CC: ${cc}\n` : ''}${bcc !== 'None' ? `BCC: ${bcc}\n` : ''}Subject: ${email.subject} ${cc !== 'None' ? `CC: ${cc}\n` : ''}${bcc !== 'None' ? `BCC: ${bcc}\n` : ''}Subject: ${email.subject}
@ -55,55 +53,54 @@ ${body}`;
*/ */
async function handleReadMultipleEmails(args) { async function handleReadMultipleEmails(args) {
const emailIds = args.ids; const emailIds = args.ids;
const mb = normalizeMailbox(args.mailbox);
const opts = { headers: withMailboxHeaders(mb) };
if (!emailIds || !Array.isArray(emailIds) || emailIds.length === 0) { if (!emailIds || !Array.isArray(emailIds) || emailIds.length === 0) {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Email IDs array is required and must contain at least one ID." text: "Email IDs array is required and must contain at least one ID."
}] }]
}; };
} }
// Limit the number of emails to prevent overwhelming responses
const maxEmails = 10; const maxEmails = 10;
if (emailIds.length > maxEmails) { if (emailIds.length > maxEmails) {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: `Too many email IDs provided. Maximum allowed is ${maxEmails}, but ${emailIds.length} were provided.` text: `Too many email IDs provided. Maximum allowed is ${maxEmails}, but ${emailIds.length} were provided.`
}] }]
}; };
} }
try { try {
// Get access token
const accessToken = await ensureAuthenticated(); const accessToken = await ensureAuthenticated();
// Create concurrent API calls for all email IDs
const emailPromises = emailIds.map(async (emailId) => { const emailPromises = emailIds.map(async (emailId) => {
try { try {
const endpoint = `me/messages/${encodeURIComponent(emailId)}`; const endpoint = buildPath(mb, `messages/${emailId}`);
const queryParams = { const queryParams = {
$select: config.EMAIL_DETAIL_FIELDS $select: config.EMAIL_DETAIL_FIELDS
}; };
const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams); const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams, opts);
return { emailId, email, error: null }; return { emailId, email, error: null };
} catch (error) { } catch (error) {
console.error(`Error reading email ${emailId}: ${error.message}`); console.error(`Error reading email ${emailId}: ${error.message}`);
return { emailId, email: null, error: error.message }; return { emailId, email: null, error: error.message };
} }
}); });
// Wait for all API calls to complete
const results = await Promise.all(emailPromises); const results = await Promise.all(emailPromises);
// Format all emails const mailboxNote = mb.kind === 'user' ? ` Mailbox: ${mb.smtpOrUpn}.` : '';
const formattedEmails = results.map((result, index) => { const formattedEmails = results.map((result, index) => {
const emailNumber = index + 1; const emailNumber = index + 1;
const separator = "=".repeat(80); const separator = "=".repeat(80);
if (result.error) { if (result.error) {
return `${separator} return `${separator}
EMAIL ${emailNumber} (ID: ${result.emailId}) EMAIL ${emailNumber} (ID: ${result.emailId})
@ -117,15 +114,14 @@ ${separator}
${formattedEmail}`; ${formattedEmail}`;
} }
}); });
// Count successful vs failed reads
const successCount = results.filter(r => !r.error && r.email).length; const successCount = results.filter(r => !r.error && r.email).length;
const errorCount = 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` : ''}. const summary = `Retrieved ${successCount} email(s) successfully${errorCount > 0 ? `, ${errorCount} failed` : ''}.${mailboxNote}
`; `;
return { return {
content: [ content: [
{ {
@ -137,20 +133,20 @@ ${formattedEmail}`;
} catch (error) { } catch (error) {
if (error.message === 'Authentication required') { if (error.message === 'Authentication required') {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Authentication required. Please use the 'authenticate' tool first." text: "Authentication required. Please use the 'authenticate' tool first."
}] }]
}; };
} }
return { return {
content: [{ content: [{
type: "text", type: "text",
text: `Error accessing emails: ${error.message}` text: `Error accessing emails: ${error.message}`
}] }]
}; };
} }
} }
module.exports = handleReadMultipleEmails; module.exports = handleReadMultipleEmails;

View file

@ -6,6 +6,7 @@ const { callGraphAPI } = require('../utils/graph-api');
const { ensureAuthenticated } = require('../auth'); const { ensureAuthenticated } = require('../auth');
const { cleanBody } = require('../utils/bodyParser'); const { cleanBody } = require('../utils/bodyParser');
const { formatDateTime } = require('../utils/time-formatter'); const { formatDateTime } = require('../utils/time-formatter');
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
/** /**
* Read email handler * Read email handler
@ -14,29 +15,30 @@ const { formatDateTime } = require('../utils/time-formatter');
*/ */
async function handleReadEmail(args) { async function handleReadEmail(args) {
const emailId = args.id; const emailId = args.id;
const mb = normalizeMailbox(args.mailbox);
if (!emailId) { if (!emailId) {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Email ID is required." text: "Email ID is required."
}] }]
}; };
} }
try { try {
// Get access token
const accessToken = await ensureAuthenticated(); const accessToken = await ensureAuthenticated();
// Make API call to get email details // Do not pre-encode the ID — callGraphAPI encodes each path segment once.
const endpoint = `me/messages/${encodeURIComponent(emailId)}`; const endpoint = buildPath(mb, `messages/${emailId}`);
const queryParams = { const queryParams = {
$select: config.EMAIL_DETAIL_FIELDS $select: config.EMAIL_DETAIL_FIELDS
}; };
const opts = { headers: withMailboxHeaders(mb) };
try { try {
const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams); const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams, opts);
if (!email) { if (!email) {
return { return {
content: [ content: [
@ -47,24 +49,23 @@ async function handleReadEmail(args) {
] ]
}; };
} }
// Format sender, recipients, etc.
const sender = email.from ? `${email.from.emailAddress.name} (${email.from.emailAddress.address})` : 'Unknown'; 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 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 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 bcc = email.bccRecipients && email.bccRecipients.length > 0 ? email.bccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None';
const date = formatDateTime(email.receivedDateTime); const date = formatDateTime(email.receivedDateTime);
// Extract and clean body content (cleanBody handles both HTML and plain text)
let body = ''; let body = '';
if (email.body) { if (email.body) {
body = cleanBody(email.body.content); body = cleanBody(email.body.content);
} else { } else {
body = cleanBody(email.bodyPreview) || 'No content'; body = cleanBody(email.bodyPreview) || 'No content';
} }
// Format the email const mailboxLine = mb.kind === 'user' ? `Mailbox: ${mb.smtpOrUpn}\n` : '';
const formattedEmail = `From: ${sender}
const formattedEmail = `${mailboxLine}From: ${sender}
To: ${to} To: ${to}
${cc !== 'None' ? `CC: ${cc}\n` : ''}${bcc !== 'None' ? `BCC: ${bcc}\n` : ''}Subject: ${email.subject} ${cc !== 'None' ? `CC: ${cc}\n` : ''}${bcc !== 'None' ? `BCC: ${bcc}\n` : ''}Subject: ${email.subject}
Date: ${date} Date: ${date}
@ -72,7 +73,7 @@ Importance: ${email.importance || 'normal'}
Has Attachments: ${email.hasAttachments ? 'Yes' : 'No'} Has Attachments: ${email.hasAttachments ? 'Yes' : 'No'}
${body}`; ${body}`;
return { return {
content: [ content: [
{ {
@ -83,14 +84,13 @@ ${body}`;
}; };
} catch (error) { } catch (error) {
console.error(`Error reading email: ${error.message}`); 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")) { if (error.message.includes("doesn't belong to the targeted mailbox")) {
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: `The email ID seems invalid or doesn't belong to your mailbox. Please try with a different email ID.` 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.`
} }
] ]
}; };
@ -108,16 +108,16 @@ ${body}`;
} catch (error) { } catch (error) {
if (error.message === 'Authentication required') { if (error.message === 'Authentication required') {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Authentication required. Please use the 'authenticate' tool first." text: "Authentication required. Please use the 'authenticate' tool first."
}] }]
}; };
} }
return { return {
content: [{ content: [{
type: "text", type: "text",
text: `Error accessing email: ${error.message}` text: `Error accessing email: ${error.message}`
}] }]
}; };

View file

@ -7,6 +7,7 @@ const { ensureAuthenticated } = require('../auth');
const { resolveFolderPath } = require('./folder-utils'); const { resolveFolderPath } = require('./folder-utils');
const { formatDateTime } = require('../utils/time-formatter'); const { formatDateTime } = require('../utils/time-formatter');
const { buildODataFilter, buildDateFilter } = require('../utils/odata-helpers'); const { buildODataFilter, buildDateFilter } = require('../utils/odata-helpers');
const { normalizeMailbox, withMailboxHeaders } = require('../utils/mailbox');
/** /**
* Search emails handler * Search emails handler
@ -25,6 +26,8 @@ async function handleSearchEmails(args) {
const hasAttachments = args.hasAttachments === true || args.hasAttachments === 'true' ? true : undefined; const hasAttachments = args.hasAttachments === true || args.hasAttachments === 'true' ? true : undefined;
const unreadOnly = args.unreadOnly === true || args.unreadOnly === 'true' ? true : undefined; const unreadOnly = args.unreadOnly === true || args.unreadOnly === 'true' ? true : undefined;
const strict = args.strict === true || args.strict === 'true'; 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. // Date filtering uses the same timezone-aware helpers as list-emails.
const dateFrom = args.dateFrom || ''; const dateFrom = args.dateFrom || '';
@ -36,8 +39,8 @@ async function handleSearchEmails(args) {
const accessToken = await ensureAuthenticated(); const accessToken = await ensureAuthenticated();
// Resolve the folder path // Resolve the folder path
const endpoint = await resolveFolderPath(accessToken, folder); const endpoint = await resolveFolderPath(accessToken, folder, mb);
console.error(`Using endpoint: ${endpoint} for folder: ${folder}`); console.error(`Using endpoint: ${endpoint} for folder: ${folder} mailbox=${mb.graphRoot}`);
// Execute progressive search // Execute progressive search
const response = await progressiveSearch( const response = await progressiveSearch(
@ -47,10 +50,11 @@ async function handleSearchEmails(args) {
{ hasAttachments, unreadOnly }, { hasAttachments, unreadOnly },
count, count,
strict, strict,
{ dateFrom, dateTo, dateRange } { dateFrom, dateTo, dateRange },
graphOpts
); );
return formatSearchResults(response, { dateFrom, dateTo, dateRange }); return formatSearchResults(response, { dateFrom, dateTo, dateRange, mailbox: mb.kind === 'user' ? mb.smtpOrUpn : null });
} catch (error) { } catch (error) {
// Handle authentication errors // Handle authentication errors
if (error.message === 'Authentication required') { if (error.message === 'Authentication required') {
@ -87,7 +91,7 @@ async function handleSearchEmails(args) {
* 3. Only boolean filters $filter + $orderby (fully supported) * 3. Only boolean filters $filter + $orderby (fully supported)
* 4. Fallback recent emails (only when not in strict mode; marked clearly) * 4. Fallback recent emails (only when not in strict mode; marked clearly)
*/ */
async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms, count, strict = false, dateOpts = {}) { async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms, count, strict = false, dateOpts = {}, graphOpts = {}) {
const hasTextTerms = !!(searchTerms.query || searchTerms.from || searchTerms.to || searchTerms.subject); const hasTextTerms = !!(searchTerms.query || searchTerms.from || searchTerms.to || searchTerms.subject);
const hasBooleanFilters = filterTerms.hasAttachments === true || filterTerms.unreadOnly === true; const hasBooleanFilters = filterTerms.hasAttachments === true || filterTerms.unreadOnly === true;
const hasDateFilters = !!(dateOpts.dateFrom || dateOpts.dateTo || dateOpts.dateRange); const hasDateFilters = !!(dateOpts.dateFrom || dateOpts.dateTo || dateOpts.dateRange);
@ -133,7 +137,7 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms
}; };
console.error(`Attempting combined KQL search: ${kqlQuery}`); console.error(`Attempting combined KQL search: ${kqlQuery}`);
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params); const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params, graphOpts);
if (response.value && response.value.length > 0) { if (response.value && response.value.length > 0) {
let filtered = applyClientSideFilters(response.value, filterTerms); let filtered = applyClientSideFilters(response.value, filterTerms);
@ -162,7 +166,7 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms
}; };
console.error(`Attempting single-term search (${term}): ${kqlQuery}`); console.error(`Attempting single-term search (${term}): ${kqlQuery}`);
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params); const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params, graphOpts);
if (response.value && response.value.length > 0) { if (response.value && response.value.length > 0) {
let filtered = applyClientSideFilters(response.value, filterTerms); let filtered = applyClientSideFilters(response.value, filterTerms);
@ -194,7 +198,7 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms
}; };
console.error(`Attempting filter-only search: ${params.$filter}`); console.error(`Attempting filter-only search: ${params.$filter}`);
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params); const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params, graphOpts);
console.error(`Filter-only search found ${response.value?.length || 0} results`); console.error(`Filter-only search found ${response.value?.length || 0} results`);
return response; return response;
} catch (error) { } catch (error) {
@ -219,7 +223,7 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms
basicParams.$filter = dateFilterString; basicParams.$filter = dateFilterString;
} }
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, basicParams); const response = await callGraphAPI(accessToken, 'GET', endpoint, null, basicParams, graphOpts);
console.error(`Fallback to recent emails found ${response.value?.length || 0} results`); console.error(`Fallback to recent emails found ${response.value?.length || 0} results`);
if (dateFilterString) { if (dateFilterString) {
@ -318,6 +322,7 @@ function formatSearchResults(response, dateOpts = {}) {
if (dateOpts.dateRange) dateParts.push(`dateRange: ${dateOpts.dateRange}`); if (dateOpts.dateRange) dateParts.push(`dateRange: ${dateOpts.dateRange}`);
if (dateOpts.dateFrom) dateParts.push(`from: ${dateOpts.dateFrom}`); if (dateOpts.dateFrom) dateParts.push(`from: ${dateOpts.dateFrom}`);
if (dateOpts.dateTo) dateParts.push(`to: ${dateOpts.dateTo}`); if (dateOpts.dateTo) dateParts.push(`to: ${dateOpts.dateTo}`);
if (dateOpts.mailbox) dateParts.push(`mailbox: ${dateOpts.mailbox}`);
const dateInfo = dateParts.length > 0 ? ` (${dateParts.join(', ')})` : ''; const dateInfo = dateParts.length > 0 ? ` (${dateParts.join(', ')})` : '';
return { return {

View file

@ -4,6 +4,7 @@
const config = require('../config'); const config = require('../config');
const { callGraphAPI } = require('../utils/graph-api'); const { callGraphAPI } = require('../utils/graph-api');
const { ensureAuthenticated } = require('../auth'); const { ensureAuthenticated } = require('../auth');
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
/** /**
* Send email handler * Send email handler
@ -11,41 +12,51 @@ const { ensureAuthenticated } = require('../auth');
* @returns {object} - MCP response * @returns {object} - MCP response
*/ */
async function handleSendEmail(args) { async function handleSendEmail(args) {
const { to, cc, bcc, subject, body, importance = 'normal', saveToSentItems = true } = args; const {
to,
// Validate required parameters cc,
bcc,
subject,
body,
importance = 'normal',
saveToSentItems = true,
from: fromArg,
onBehalfOf = false
} = args;
const mb = normalizeMailbox(args.mailbox);
const onBehalf = onBehalfOf === true || onBehalfOf === 'true';
if (!to) { if (!to) {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Recipient (to) is required." text: "Recipient (to) is required."
}] }]
}; };
} }
if (!subject) { if (!subject) {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Subject is required." text: "Subject is required."
}] }]
}; };
} }
if (!body) { if (!body) {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Body content is required." text: "Body content is required."
}] }]
}; };
} }
try { try {
// Get access token
const accessToken = await ensureAuthenticated(); const accessToken = await ensureAuthenticated();
// Format recipients
const toRecipients = to.split(',').map(email => { const toRecipients = to.split(',').map(email => {
email = email.trim(); email = email.trim();
return { return {
@ -54,7 +65,7 @@ async function handleSendEmail(args) {
} }
}; };
}); });
const ccRecipients = cc ? cc.split(',').map(email => { const ccRecipients = cc ? cc.split(',').map(email => {
email = email.trim(); email = email.trim();
return { return {
@ -63,7 +74,7 @@ async function handleSendEmail(args) {
} }
}; };
}) : []; }) : [];
const bccRecipients = bcc ? bcc.split(',').map(email => { const bccRecipients = bcc ? bcc.split(',').map(email => {
email = email.trim(); email = email.trim();
return { return {
@ -72,45 +83,83 @@ async function handleSendEmail(args) {
} }
}; };
}) : []; }) : [];
// Prepare email object const fromAddr = (fromArg && String(fromArg).trim())
const emailObject = { || (mb.kind === 'user' ? mb.smtpOrUpn : null);
message: {
subject, // Default: when mailbox is a shared UPN, send via users/{upn}/sendMail (Send As style).
body: { // onBehalfOf=true forces me/sendMail with from=shared (Send on Behalf style).
contentType: body.includes('<html') ? 'html' : 'text', let sendPath = 'me/sendMail';
content: body let anchorMb = mb;
}, if (mb.kind === 'user' && !onBehalf) {
toRecipients, sendPath = buildPath(mb, 'sendMail');
ccRecipients: ccRecipients.length > 0 ? ccRecipients : undefined, } else if (fromAddr && onBehalf) {
bccRecipients: bccRecipients.length > 0 ? bccRecipients : undefined, sendPath = 'me/sendMail';
importance anchorMb = normalizeMailbox(fromAddr);
} else if (mb.kind === 'user') {
sendPath = buildPath(mb, 'sendMail');
}
const message = {
subject,
body: {
// New Outlook + Graph: prefer HTML when the body looks like a document.
// Match <html, <!doctype html, or a leading HTML fragment with common tags.
contentType: /<!DOCTYPE\s+html|<html[\s>]|<(?:table|div|h[1-6]|p)\b/i.test(body || '')
? 'HTML'
: 'Text',
content: body
}, },
toRecipients,
ccRecipients: ccRecipients.length > 0 ? ccRecipients : undefined,
bccRecipients: bccRecipients.length > 0 ? bccRecipients : undefined,
importance
};
if (fromAddr) {
message.from = {
emailAddress: {
address: fromAddr
}
};
}
const emailObject = {
message,
saveToSentItems saveToSentItems
}; };
// Make API call to send email await callGraphAPI(
await callGraphAPI(accessToken, 'POST', 'me/sendMail', emailObject); accessToken,
'POST',
sendPath,
emailObject,
null,
{ headers: withMailboxHeaders(anchorMb) }
);
const fromNote = fromAddr ? `\nFrom: ${fromAddr}${onBehalf ? ' (on behalf)' : ''}` : '';
const mailboxNote = mb.kind === 'user' ? `\nMailbox: ${mb.smtpOrUpn}` : '';
return { return {
content: [{ content: [{
type: "text", type: "text",
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` 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`
}] }]
}; };
} catch (error) { } catch (error) {
if (error.message === 'Authentication required') { if (error.message === 'Authentication required') {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Authentication required. Please use the 'authenticate' tool first." text: "Authentication required. Please use the 'authenticate' tool first."
}] }]
}; };
} }
return { return {
content: [{ content: [{
type: "text", type: "text",
text: `Error sending email: ${error.message}` text: `Error sending email: ${error.message}`
}] }]
}; };

View file

@ -4,6 +4,7 @@
const { callGraphAPI } = require('../utils/graph-api'); const { callGraphAPI } = require('../utils/graph-api');
const { ensureAuthenticated } = require('../auth'); const { ensureAuthenticated } = require('../auth');
const { getFolderIdByName } = require('../email/folder-utils'); const { getFolderIdByName } = require('../email/folder-utils');
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
/** /**
* Create folder handler * Create folder handler
@ -13,42 +14,40 @@ const { getFolderIdByName } = require('../email/folder-utils');
async function handleCreateFolder(args) { async function handleCreateFolder(args) {
const folderName = args.name; const folderName = args.name;
const parentFolder = args.parentFolder || ''; const parentFolder = args.parentFolder || '';
const mb = normalizeMailbox(args.mailbox);
if (!folderName) { if (!folderName) {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Folder name is required." text: "Folder name is required."
}] }]
}; };
} }
try { try {
// Get access token
const accessToken = await ensureAuthenticated(); 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 { return {
content: [{ content: [{
type: "text", type: "text",
text: result.message text: result.message
}] }]
}; };
} catch (error) { } catch (error) {
if (error.message === 'Authentication required') { if (error.message === 'Authentication required') {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Authentication required. Please use the 'authenticate' tool first." text: "Authentication required. Please use the 'authenticate' tool first."
}] }]
}; };
} }
return { return {
content: [{ content: [{
type: "text", type: "text",
text: `Error creating folder: ${error.message}` text: `Error creating folder: ${error.message}`
}] }]
}; };
@ -57,56 +56,53 @@ async function handleCreateFolder(args) {
/** /**
* Create a new mail folder * 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) { async function createMailFolder(accessToken, folderName, parentFolderName, mb) {
const opts = { headers: withMailboxHeaders(mb) };
try { try {
// Check if a folder with this name already exists const existingFolder = await getFolderIdByName(accessToken, folderName, mb);
const existingFolder = await getFolderIdByName(accessToken, folderName);
if (existingFolder) { if (existingFolder) {
return { return {
success: false, success: false,
message: `A folder named "${folderName}" already exists.` message: `A folder named "${folderName}" already exists${mb.kind === 'user' ? ` in ${mb.smtpOrUpn}` : ''}.`
}; };
} }
// If parent folder specified, find its ID let endpoint = buildPath(mb, 'mailFolders');
let endpoint = 'me/mailFolders';
if (parentFolderName) { if (parentFolderName) {
const parentId = await getFolderIdByName(accessToken, parentFolderName); const parentId = await getFolderIdByName(accessToken, parentFolderName, mb);
if (!parentId) { if (!parentId) {
return { return {
success: false, success: false,
message: `Parent folder "${parentFolderName}" not found. Please specify a valid parent folder or leave it blank to create at the root level.` message: `Parent folder "${parentFolderName}" not found. Please specify a valid parent folder or leave it blank to create at the root level.`
}; };
} }
endpoint = `me/mailFolders/${parentId}/childFolders`; endpoint = buildPath(mb, `mailFolders/${parentId}/childFolders`);
} }
// Create the folder
const folderData = { const folderData = {
displayName: folderName displayName: folderName
}; };
const response = await callGraphAPI( const response = await callGraphAPI(
accessToken, accessToken,
'POST', 'POST',
endpoint, endpoint,
folderData folderData,
null,
opts
); );
if (response && response.id) { if (response && response.id) {
const locationInfo = parentFolderName const locationInfo = parentFolderName
? `inside "${parentFolderName}"` ? `inside "${parentFolderName}"`
: "at the root level"; : "at the root level";
const mailboxInfo = mb.kind === 'user' ? ` (mailbox: ${mb.smtpOrUpn})` : '';
return { return {
success: true, success: true,
message: `Successfully created folder "${folderName}" ${locationInfo}.`, message: `Successfully created folder "${folderName}" ${locationInfo}${mailboxInfo}.`,
folderId: response.id folderId: response.id
}; };
} else { } else {

View file

@ -5,11 +5,16 @@ const { handleListFolders } = require('./list');
const handleCreateFolder = require('./create'); const handleCreateFolder = require('./create');
const handleMoveEmails = require('./move'); 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 // Folder management tool definitions
const folderTools = [ const folderTools = [
{ {
name: "list-folders", name: "list-folders",
description: "Lists mail folders in your Outlook account", description: "Lists mail folders in your Outlook account (or a shared mailbox when mailbox is set)",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@ -20,7 +25,8 @@ const folderTools = [
includeChildren: { includeChildren: {
anyOf: [{ type: "boolean" }, { type: "string" }], anyOf: [{ type: "boolean" }, { type: "string" }],
description: "Include child folders in hierarchy" description: "Include child folders in hierarchy"
} },
mailbox: mailboxProp
}, },
required: [] required: []
}, },
@ -28,7 +34,7 @@ const folderTools = [
}, },
{ {
name: "create-folder", name: "create-folder",
description: "Creates a new mail folder", description: "Creates a new mail folder. There is no delete-folder tool — avoid test folders on shared mailboxes.",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@ -39,7 +45,8 @@ const folderTools = [
parentFolder: { parentFolder: {
type: "string", type: "string",
description: "Optional parent folder name (default is root)" description: "Optional parent folder name (default is root)"
} },
mailbox: mailboxProp
}, },
required: ["name"] required: ["name"]
}, },
@ -47,7 +54,7 @@ const folderTools = [
}, },
{ {
name: "move-emails", name: "move-emails",
description: "Moves emails from one folder to another", description: "Moves emails from one folder to another within the same mailbox",
inputSchema: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@ -62,7 +69,8 @@ const folderTools = [
sourceFolder: { sourceFolder: {
type: "string", type: "string",
description: "Optional name of the source folder (default is inbox)" description: "Optional name of the source folder (default is inbox)"
} },
mailbox: mailboxProp
}, },
required: ["emailIds", "targetFolder"] required: ["emailIds", "targetFolder"]
}, },

View file

@ -3,6 +3,7 @@
*/ */
const { callGraphAPI } = require('../utils/graph-api'); const { callGraphAPI } = require('../utils/graph-api');
const { ensureAuthenticated } = require('../auth'); const { ensureAuthenticated } = require('../auth');
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
/** /**
* List folders handler * List folders handler
@ -12,44 +13,47 @@ const { ensureAuthenticated } = require('../auth');
async function handleListFolders(args) { async function handleListFolders(args) {
const includeItemCounts = args.includeItemCounts === true || args.includeItemCounts === 'true'; const includeItemCounts = args.includeItemCounts === true || args.includeItemCounts === 'true';
const includeChildren = args.includeChildren === true || args.includeChildren === 'true'; const includeChildren = args.includeChildren === true || args.includeChildren === 'true';
const mb = normalizeMailbox(args.mailbox);
try { try {
// Get access token // Get access token
const accessToken = await ensureAuthenticated(); const accessToken = await ensureAuthenticated();
// Get all mail folders // Get all mail folders
const folders = await getAllFoldersHierarchy(accessToken, includeItemCounts); const folders = await getAllFoldersHierarchy(accessToken, includeItemCounts, mb);
const mailboxPrefix = mb.kind === 'user' ? `Mailbox: ${mb.smtpOrUpn}\n\n` : '';
// If including children, format as hierarchy // If including children, format as hierarchy
if (includeChildren) { if (includeChildren) {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: formatFolderHierarchy(folders, includeItemCounts) text: mailboxPrefix + formatFolderHierarchy(folders, includeItemCounts)
}] }]
}; };
} else { } else {
// Otherwise, format as flat list // Otherwise, format as flat list
return { return {
content: [{ content: [{
type: "text", type: "text",
text: formatFolderList(folders, includeItemCounts) text: mailboxPrefix + formatFolderList(folders, includeItemCounts)
}] }]
}; };
} }
} catch (error) { } catch (error) {
if (error.message === 'Authentication required') { if (error.message === 'Authentication required') {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Authentication required. Please use the 'authenticate' tool first." text: "Authentication required. Please use the 'authenticate' tool first."
}] }]
}; };
} }
return { return {
content: [{ content: [{
type: "text", type: "text",
text: `Error listing folders: ${error.message}` text: `Error listing folders: ${error.message}`
}] }]
}; };
@ -60,50 +64,54 @@ async function handleListFolders(args) {
* Get all mail folders with hierarchy information * Get all mail folders with hierarchy information
* @param {string} accessToken - Access token * @param {string} accessToken - Access token
* @param {boolean} includeItemCounts - Include item counts in response * @param {boolean} includeItemCounts - Include item counts in response
* @param {object} mb - normalizeMailbox result
* @returns {Promise<Array>} - Array of folder objects with hierarchy * @returns {Promise<Array>} - Array of folder objects with hierarchy
*/ */
async function getAllFoldersHierarchy(accessToken, includeItemCounts) { async function getAllFoldersHierarchy(accessToken, includeItemCounts, mb = normalizeMailbox(null)) {
try { try {
const opts = { headers: withMailboxHeaders(mb) };
// Determine select fields based on whether to include counts // Determine select fields based on whether to include counts
const selectFields = includeItemCounts const selectFields = includeItemCounts
? 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount' ? 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount'
: 'id,displayName,parentFolderId,childFolderCount'; : 'id,displayName,parentFolderId,childFolderCount';
// Get all mail folders // Get all mail folders
const response = await callGraphAPI( const response = await callGraphAPI(
accessToken, accessToken,
'GET', 'GET',
'me/mailFolders', buildPath(mb, 'mailFolders'),
null, null,
{ {
$top: 100, $top: 100,
$select: selectFields $select: selectFields
} },
opts
); );
if (!response.value) { if (!response.value) {
return []; return [];
} }
// Get child folders for folders with children // Get child folders for folders with children
const foldersWithChildren = response.value.filter(f => f.childFolderCount > 0); const foldersWithChildren = response.value.filter(f => f.childFolderCount > 0);
const childFolderPromises = foldersWithChildren.map(async (folder) => { const childFolderPromises = foldersWithChildren.map(async (folder) => {
try { try {
const childResponse = await callGraphAPI( const childResponse = await callGraphAPI(
accessToken, accessToken,
'GET', 'GET',
`me/mailFolders/${folder.id}/childFolders`, buildPath(mb, `mailFolders/${folder.id}/childFolders`),
null, null,
{ $select: selectFields } { $select: selectFields },
opts
); );
// Add parent folder info to each child // Add parent folder info to each child
const childFolders = childResponse.value || []; const childFolders = childResponse.value || [];
childFolders.forEach(child => { childFolders.forEach(child => {
child.parentFolder = folder.displayName; child.parentFolder = folder.displayName;
}); });
return childFolders; return childFolders;
} catch (error) { } catch (error) {
console.error(`Error getting child folders for "${folder.displayName}": ${error.message}`); console.error(`Error getting child folders for "${folder.displayName}": ${error.message}`);

View file

@ -4,6 +4,7 @@
const { callGraphAPI } = require('../utils/graph-api'); const { callGraphAPI } = require('../utils/graph-api');
const { ensureAuthenticated } = require('../auth'); const { ensureAuthenticated } = require('../auth');
const { getFolderIdByName } = require('../email/folder-utils'); const { getFolderIdByName } = require('../email/folder-utils');
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
/** /**
* Move emails handler * Move emails handler
@ -14,63 +15,61 @@ async function handleMoveEmails(args) {
const emailIds = args.emailIds || ''; const emailIds = args.emailIds || '';
const targetFolder = args.targetFolder || ''; const targetFolder = args.targetFolder || '';
const sourceFolder = args.sourceFolder || ''; const sourceFolder = args.sourceFolder || '';
const mb = normalizeMailbox(args.mailbox);
if (!emailIds) { if (!emailIds) {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Email IDs are required. Please provide a comma-separated list of email IDs to move." text: "Email IDs are required. Please provide a comma-separated list of email IDs to move."
}] }]
}; };
} }
if (!targetFolder) { if (!targetFolder) {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Target folder name is required." text: "Target folder name is required."
}] }]
}; };
} }
try { try {
// Get access token
const accessToken = await ensureAuthenticated(); const accessToken = await ensureAuthenticated();
// Parse email IDs
const ids = emailIds.split(',').map(id => id.trim()).filter(id => id); const ids = emailIds.split(',').map(id => id.trim()).filter(id => id);
if (ids.length === 0) { if (ids.length === 0) {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "No valid email IDs provided." text: "No valid email IDs provided."
}] }]
}; };
} }
// Move emails const result = await moveEmailsToFolder(accessToken, ids, targetFolder, sourceFolder, mb);
const result = await moveEmailsToFolder(accessToken, ids, targetFolder, sourceFolder);
return { return {
content: [{ content: [{
type: "text", type: "text",
text: result.message text: result.message
}] }]
}; };
} catch (error) { } catch (error) {
if (error.message === 'Authentication required') { if (error.message === 'Authentication required') {
return { return {
content: [{ content: [{
type: "text", type: "text",
text: "Authentication required. Please use the 'authenticate' tool first." text: "Authentication required. Please use the 'authenticate' tool first."
}] }]
}; };
} }
return { return {
content: [{ content: [{
type: "text", type: "text",
text: `Error moving emails: ${error.message}` text: `Error moving emails: ${error.message}`
}] }]
}; };
@ -79,42 +78,36 @@ async function handleMoveEmails(args) {
/** /**
* Move emails to a folder * 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) { async function moveEmailsToFolder(accessToken, emailIds, targetFolderName, sourceFolderName, mb) {
const opts = { headers: withMailboxHeaders(mb) };
try { try {
// Get the target folder ID const targetFolderId = await getFolderIdByName(accessToken, targetFolderName, mb);
const targetFolderId = await getFolderIdByName(accessToken, targetFolderName);
if (!targetFolderId) { if (!targetFolderId) {
return { return {
success: false, success: false,
message: `Target folder "${targetFolderName}" not found. Please specify a valid folder name.` message: `Target folder "${targetFolderName}" not found${mb.kind === 'user' ? ` in ${mb.smtpOrUpn}` : ''}. Please specify a valid folder name.`
}; };
} }
// Track successful and failed moves
const results = { const results = {
successful: [], successful: [],
failed: [] failed: []
}; };
// Process each email one by one to handle errors independently
for (const emailId of emailIds) { for (const emailId of emailIds) {
try { try {
// Move the email
await callGraphAPI( await callGraphAPI(
accessToken, accessToken,
'POST', 'POST',
`me/messages/${emailId}/move`, buildPath(mb, `messages/${emailId}/move`),
{ {
destinationId: targetFolderId destinationId: targetFolderId
} },
null,
opts
); );
results.successful.push(emailId); results.successful.push(emailId);
} catch (error) { } catch (error) {
console.error(`Error moving email ${emailId}: ${error.message}`); console.error(`Error moving email ${emailId}: ${error.message}`);
@ -124,31 +117,30 @@ async function moveEmailsToFolder(accessToken, emailIds, targetFolderName, sourc
}); });
} }
} }
// Generate result message
let message = ''; let message = '';
if (results.successful.length > 0) { if (results.successful.length > 0) {
message += `Successfully moved ${results.successful.length} email(s) to "${targetFolderName}".`; message += `Successfully moved ${results.successful.length} email(s) to "${targetFolderName}"`;
if (mb.kind === 'user') message += ` (${mb.smtpOrUpn})`;
message += '.';
} }
if (results.failed.length > 0) { if (results.failed.length > 0) {
if (message) message += '\n\n'; if (message) message += '\n\n';
message += `Failed to move ${results.failed.length} email(s). Errors:`; message += `Failed to move ${results.failed.length} email(s). Errors:`;
// Show first few errors with details
const maxErrors = Math.min(results.failed.length, 3); const maxErrors = Math.min(results.failed.length, 3);
for (let i = 0; i < maxErrors; i++) { for (let i = 0; i < maxErrors; i++) {
const failure = results.failed[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) { if (results.failed.length > maxErrors) {
message += `\n...and ${results.failed.length - maxErrors} more.`; message += `\n...and ${results.failed.length - maxErrors} more.`;
} }
} }
return { return {
success: results.successful.length > 0, success: results.successful.length > 0,
message, message,

View file

@ -8,43 +8,47 @@
* INSTRUCTIONS FOR AI MODELS: * INSTRUCTIONS FOR AI MODELS:
* This server provides comprehensive Outlook integration with the following capabilities: * This server provides comprehensive Outlook integration with the following capabilities:
* *
* 🔐 AUTHENTICATION (Required First): * AUTHENTICATION (Required First):
* - Use `check-auth-status()` to verify authentication * - Use `check-auth-status()` to verify authentication (also reports shared-mailbox scopes)
* - Use `authenticate()` if not authenticated (follow the provided URL) * - Use `authenticate()` if not authenticated (follow the provided URL)
* *
* 📧 EMAIL MANAGEMENT: * EMAIL MANAGEMENT:
* - `list-emails()` - List emails with advanced date filtering. Results include conversationId. * - `list-emails()` - List emails with advanced date filtering. Results include conversationId.
* - `search-emails({ from, subject, query, unreadOnly, hasAttachments })` - Search emails. 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-email({ id })` - Read full email content (body auto-cleaned)
* - `read-emails({ ids: [id1, id2] })` - Read multiple emails at once (max: 10, bodies 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). Use conversationId from list-emails or search-emails. Fallback: pass ids array of specific message IDs. * - `get-email-thread({ conversationId })` - Fetch a complete deduplicated thread (quoted replies stripped).
* - `send-email({ to, subject, body })` - Send new emails * - `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 * 1. search-emails() or list-emails() get conversationId from results
* 2. get-email-thread({ conversationId }) read the full thread efficiently * 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 * - `list-events()` - List calendar events
* - `create-event({ subject, start, end })` - Create meetings * - `create-event({ subject, start, end })` - Create meetings
* - `decline-event()`, `cancel-event()` - Respond to invitations * - `decline-event()`, `cancel-event()` - Respond to invitations
* *
* 📁 FOLDER MANAGEMENT: * FOLDER MANAGEMENT:
* - `list-folders()` - List mail folders * - `list-folders()` - List mail folders
* - `create-folder({ name })` - Create new folders * - `create-folder({ name })` - Create new folders
* - `move-emails({ emailIds, targetFolder })` - Organize emails * - `move-emails({ emailIds, targetFolder })` - Organize emails
* *
* 📋 EMAIL RULES: * EMAIL RULES:
* - `list-rules()` - List inbox rules * - `list-rules()` - List inbox rules
* - `create-rule({ name, conditions, actions })` - Automate email handling * - `create-rule({ name, conditions, actions })` - Automate email handling
* *
* 💡 KEY FEATURES: * KEY FEATURES:
* - Date filtering: Use dateRange ("today", "last7days") or dateFrom/dateTo * - Date filtering: Use dateRange ("today", "last7days") or dateFrom/dateTo
* - High limits: Up to 500 emails/events (WARNING: may consume significant tokens) * - High limits: Up to 500 emails/events (WARNING: may consume significant tokens)
* - Comprehensive search: Filter by sender, subject, attachments, read status * - Comprehensive search: Filter by sender, subject, attachments, read status
* - Full automation: Create rules for automatic email organization * - 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 MCP_TOOLS_GUIDE.md * For complete documentation, see README.md
*/ */
const { Server } = require("@modelcontextprotocol/sdk/server/index.js"); const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js"); const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
@ -55,10 +59,12 @@ const { emailTools } = require('./email');
const { folderTools } = require('./folder'); const { folderTools } = require('./folder');
const { rulesTools } = require('./rules'); const { rulesTools } = require('./rules');
const { threadTool } = require('./tools/get-email-thread'); const { threadTool } = require('./tools/get-email-thread');
const { mailboxTools } = require('./mailbox');
// Log startup information // Log startup information
console.error(`STARTING ${config.SERVER_NAME.toUpperCase()} MCP SERVER`); console.error(`STARTING ${config.SERVER_NAME.toUpperCase()} MCP SERVER v${config.SERVER_VERSION}`);
console.error(`Test mode is ${config.USE_TEST_MODE ? 'enabled' : 'disabled'}`); 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) { if (config.DEBUG_MODE) {
console.error(`[DEBUG] Current Working Directory: ${process.cwd()}`); console.error(`[DEBUG] Current Working Directory: ${process.cwd()}`);
console.error(`[DEBUG] MS_CLIENT_ID: ${process.env.MS_CLIENT_ID ? 'SET' : 'NOT SET'}`); console.error(`[DEBUG] MS_CLIENT_ID: ${process.env.MS_CLIENT_ID ? 'SET' : 'NOT SET'}`);
@ -72,7 +78,8 @@ const TOOLS = [
...emailTools, ...emailTools,
...folderTools, ...folderTools,
...rulesTools, ...rulesTools,
threadTool threadTool,
...(config.ENABLE_SHARED_MAILBOXES ? mailboxTools : [])
]; ];
// Create server with tools capabilities // Create server with tools capabilities
@ -93,7 +100,7 @@ server.fallbackRequestHandler = async (request) => {
try { try {
const { method, params, id } = request; const { method, params, id } = request;
console.error(`REQUEST: ${method} [${id}]`); console.error(`REQUEST: ${method} [${id}]`);
// Initialize handler // Initialize handler
if (method === "initialize") { if (method === "initialize") {
console.error(`INITIALIZE REQUEST: ID [${id}]`); console.error(`INITIALIZE REQUEST: ID [${id}]`);
@ -108,17 +115,17 @@ server.fallbackRequestHandler = async (request) => {
serverInfo: { serverInfo: {
name: config.SERVER_NAME, name: config.SERVER_NAME,
version: config.SERVER_VERSION, version: config.SERVER_VERSION,
description: "Comprehensive Outlook integration with email, calendar, folders, and rules management. See MCP_TOOLS_GUIDE.md for complete documentation." description: "Comprehensive Outlook integration with email, calendar, folders, rules, and shared mailboxes. See README.md for complete documentation."
} }
}; };
} }
// Tools list handler // Tools list handler
if (method === "tools/list") { if (method === "tools/list") {
console.error(`TOOLS LIST REQUEST: ID [${id}]`); console.error(`TOOLS LIST REQUEST: ID [${id}]`);
console.error(`TOOLS COUNT: ${TOOLS.length}`); console.error(`TOOLS COUNT: ${TOOLS.length}`);
console.error(`TOOLS NAMES: ${TOOLS.map(t => t.name).join(', ')}`); console.error(`TOOLS NAMES: ${TOOLS.map(t => t.name).join(', ')}`);
return { return {
tools: TOOLS.map(tool => ({ tools: TOOLS.map(tool => ({
name: tool.name, name: tool.name,
@ -127,25 +134,25 @@ server.fallbackRequestHandler = async (request) => {
})) }))
}; };
} }
// Required empty responses for other capabilities // Required empty responses for other capabilities
if (method === "resources/list") return { resources: [] }; if (method === "resources/list") return { resources: [] };
if (method === "prompts/list") return { prompts: [] }; if (method === "prompts/list") return { prompts: [] };
// Tool call handler // Tool call handler
if (method === "tools/call") { if (method === "tools/call") {
try { try {
const { name, arguments: args = {} } = params || {}; const { name, arguments: args = {} } = params || {};
console.error(`TOOL CALL: ${name}`); console.error(`TOOL CALL: ${name}`);
// Find the tool handler // Find the tool handler
const tool = TOOLS.find(t => t.name === name); const tool = TOOLS.find(t => t.name === name);
if (tool && tool.handler) { if (tool && tool.handler) {
return await tool.handler(args); return await tool.handler(args);
} }
// Tool not found // Tool not found
return { return {
error: { error: {
@ -163,7 +170,7 @@ server.fallbackRequestHandler = async (request) => {
}; };
} }
} }
// For any other method, return method not found // For any other method, return method not found
return { return {
error: { error: {

11
mailbox/index.js Normal file
View file

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

359
mailbox/list.js Normal file
View file

@ -0,0 +1,359 @@
/**
* 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", "name": "outlook-mcp",
"version": "1.0.1", "version": "1.1.0",
"description": "MCP server for Claude to access Outlook data via Microsoft Graph API", "description": "MCP server for Claude to access Outlook data via Microsoft Graph API",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

129
tests/mailbox-path.test.js Normal file
View file

@ -0,0 +1,129 @@
/**
* 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

@ -0,0 +1,33 @@
/**
* 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,16 +13,18 @@ const config = require('../config');
const { callGraphAPI } = require('../utils/graph-api'); const { callGraphAPI } = require('../utils/graph-api');
const { ensureAuthenticated } = require('../auth'); const { ensureAuthenticated } = require('../auth');
const { buildThread } = require('../utils/threadBuilder'); const { buildThread } = require('../utils/threadBuilder');
const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox');
const MAX_MESSAGES = 20; const MAX_MESSAGES = 20;
/** /**
* Fetch all messages in a conversation across ALL folders (inbox + sent + etc.) * Fetch all messages in a conversation across ALL folders (inbox + sent + etc.)
* using the conversationId filter on the global me/messages endpoint. * using the conversationId filter on the mailbox messages endpoint.
*/ */
async function fetchByConversationId(accessToken, conversationId) { async function fetchByConversationId(accessToken, conversationId, mb) {
const allMessages = []; const allMessages = [];
let url = 'me/messages'; const opts = { headers: withMailboxHeaders(mb) };
let url = buildPath(mb, 'messages');
let params = { let params = {
$filter: `conversationId eq '${conversationId}'`, $filter: `conversationId eq '${conversationId}'`,
$select: config.EMAIL_DETAIL_FIELDS, $select: config.EMAIL_DETAIL_FIELDS,
@ -32,13 +34,10 @@ async function fetchByConversationId(accessToken, conversationId) {
// buildThread() handles chronological sorting in memory instead. // buildThread() handles chronological sorting in memory instead.
}; };
// Page through results (unlikely to exceed one page for most threads, but safe) // Single page is enough for most threads; avoid following absolute nextLink URLs
while (url) { // through callGraphAPI (path encoder is relative-path only).
const page = await callGraphAPI(accessToken, 'GET', url, null, params); const page = await callGraphAPI(accessToken, 'GET', url, null, params, opts);
if (page.value) allMessages.push(...page.value); if (page.value) allMessages.push(...page.value);
url = page['@odata.nextLink'] || null;
params = null; // params are embedded in nextLink on subsequent pages
}
return allMessages; return allMessages;
} }
@ -49,9 +48,12 @@ async function fetchByConversationId(accessToken, conversationId) {
* @param {string[]} [args.ids] - Explicit message IDs to include * @param {string[]} [args.ids] - Explicit message IDs to include
* @param {string} [args.conversationId] - Fetch entire conversation from all folders * @param {string} [args.conversationId] - Fetch entire conversation from all folders
* @param {string} [args.subject] - Optional subject label for the thread header * @param {string} [args.subject] - Optional subject label for the thread header
* @param {string} [args.mailbox] - Optional shared mailbox UPN/SMTP
*/ */
async function handleGetEmailThread(args) { async function handleGetEmailThread(args) {
const { ids, conversationId, subject } = 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 hasIds = Array.isArray(ids) && ids.length > 0;
const hasConvId = typeof conversationId === 'string' && conversationId.trim().length > 0; const hasConvId = typeof conversationId === 'string' && conversationId.trim().length > 0;
@ -81,9 +83,8 @@ async function handleGetEmailThread(args) {
let failCount = 0; let failCount = 0;
if (hasConvId) { if (hasConvId) {
// Auto-fetch entire conversation from all folders (inbox + sent + etc.)
try { try {
messages = await fetchByConversationId(accessToken, conversationId.trim()); messages = await fetchByConversationId(accessToken, conversationId.trim(), mb);
} catch (err) { } catch (err) {
console.error(`[get-email-thread] conversationId fetch failed: ${err.message}`); console.error(`[get-email-thread] conversationId fetch failed: ${err.message}`);
return { return {
@ -91,13 +92,19 @@ async function handleGetEmailThread(args) {
}; };
} }
// If caller also passed explicit IDs, merge in any that weren't in the conversation result
if (hasIds) { if (hasIds) {
const fetchedIds = new Set(messages.map(m => m.id)); const fetchedIds = new Set(messages.map(m => m.id));
const extras = await Promise.all( const extras = await Promise.all(
ids.filter(id => !fetchedIds.has(id)).map(async (id) => { ids.filter(id => !fetchedIds.has(id)).map(async (id) => {
try { try {
return await callGraphAPI(accessToken, 'GET', `me/messages/${encodeURIComponent(id)}`, null, { $select: config.EMAIL_DETAIL_FIELDS }); return await callGraphAPI(
accessToken,
'GET',
buildPath(mb, `messages/${id}`),
null,
{ $select: config.EMAIL_DETAIL_FIELDS },
opts
);
} catch (err) { } catch (err) {
console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`); console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`);
failCount++; failCount++;
@ -108,10 +115,16 @@ async function handleGetEmailThread(args) {
messages.push(...extras.filter(Boolean)); messages.push(...extras.filter(Boolean));
} }
} else { } else {
// IDs-only path — fetch concurrently, same as before
const results = await Promise.all(ids.map(async (id) => { const results = await Promise.all(ids.map(async (id) => {
try { try {
const message = await callGraphAPI(accessToken, 'GET', `me/messages/${encodeURIComponent(id)}`, null, { $select: config.EMAIL_DETAIL_FIELDS }); const message = await callGraphAPI(
accessToken,
'GET',
buildPath(mb, `messages/${id}`),
null,
{ $select: config.EMAIL_DETAIL_FIELDS },
opts
);
return { message, error: null }; return { message, error: null };
} catch (err) { } catch (err) {
console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`); console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`);
@ -124,21 +137,22 @@ async function handleGetEmailThread(args) {
if (messages.length === 0) { if (messages.length === 0) {
return { return {
content: [{ type: 'text', text: 'Could not retrieve any messages. Check IDs/conversationId and authentication.' }] content: [{ type: 'text', text: 'Could not retrieve any messages. Check IDs/conversationId, mailbox, and authentication.' }]
}; };
} }
const thread = buildThread(messages, subject); 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.)` : ''; const note = failCount > 0 ? `\n\n(Note: ${failCount} message(s) could not be fetched and are excluded.)` : '';
return { return {
content: [{ type: 'text', text: thread + note }] content: [{ type: 'text', text: thread + mbNote + note }]
}; };
} }
const threadTool = { const threadTool = {
name: 'get-email-thread', 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.', 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.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
@ -155,6 +169,10 @@ const threadTool = {
subject: { subject: {
type: 'string', type: 'string',
description: 'Optional: override the thread subject shown in the header' 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,9 +38,11 @@ function getRetryDelay(attempt, retryAfterSeconds) {
* @param {string} path - API endpoint path * @param {string} path - API endpoint path
* @param {object} data - Data to send for POST/PUT requests * @param {object} data - Data to send for POST/PUT requests
* @param {object} queryParams - Query parameters * @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 * @returns {Promise<object>} - The API response
*/ */
async function callGraphAPI(accessToken, method, path, data = null, queryParams = {}) { async function callGraphAPI(accessToken, method, path, data = null, queryParams = {}, options = {}) {
// For test tokens, we'll simulate the API call // For test tokens, we'll simulate the API call
if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) { if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) {
console.error(`TEST MODE: Simulating ${method} ${path} API call`); console.error(`TEST MODE: Simulating ${method} ${path} API call`);
@ -50,23 +52,25 @@ async function callGraphAPI(accessToken, method, path, data = null, queryParams
try { try {
console.error(`Making real API call: ${method} ${path}`); console.error(`Making real API call: ${method} ${path}`);
// Encode path segments properly // Encode path segments properly (do NOT pre-encode UPNs before calling this)
const encodedPath = path.split('/') const encodedPath = path.split('/')
.map(segment => encodeURIComponent(segment)) .map(segment => encodeURIComponent(segment))
.join('/'); .join('/');
// Build query string from parameters with special handling for OData filters // Build query string from parameters with special handling for OData filters
let queryString = ''; let queryString = '';
if (Object.keys(queryParams).length > 0) { if (queryParams && Object.keys(queryParams).length > 0) {
// Copy so we do not mutate the caller's object when deleting $filter
const qp = { ...queryParams };
// Handle $filter parameter specially to ensure proper URI encoding // Handle $filter parameter specially to ensure proper URI encoding
const filter = queryParams.$filter; const filter = qp.$filter;
if (filter) { if (filter) {
delete queryParams.$filter; // Remove from regular params delete qp.$filter; // Remove from regular params
} }
// Build query string with proper encoding for regular params // Build query string with proper encoding for regular params
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const [key, value] of Object.entries(queryParams)) { for (const [key, value] of Object.entries(qp)) {
params.append(key, value); params.append(key, value);
} }
@ -92,9 +96,10 @@ async function callGraphAPI(accessToken, method, path, data = null, queryParams
console.error(`Full URL: ${url}`); console.error(`Full URL: ${url}`);
const maxAttempts = Math.max(1, config.MAX_RETRIES + 1); const maxAttempts = Math.max(1, config.MAX_RETRIES + 1);
const extraHeaders = (options && options.headers) || {};
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = await makeSingleRequest(url, optionsForRequest(method, accessToken), data); const result = await makeSingleRequest(url, optionsForRequest(method, accessToken, extraHeaders), data);
if (result.success) { if (result.success) {
return result.body; return result.body;
@ -127,13 +132,17 @@ async function callGraphAPI(accessToken, method, path, data = null, queryParams
/** /**
* Build the https request options object. * Build the https request options object.
* @param {string} method
* @param {string} accessToken
* @param {object} [extraHeaders]
*/ */
function optionsForRequest(method, accessToken) { function optionsForRequest(method, accessToken, extraHeaders = {}) {
return { return {
method: method, method: method,
headers: { headers: {
'Authorization': `Bearer ${accessToken}`, 'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json',
...extraHeaders
} }
}; };
} }

211
utils/mailbox.js Normal file
View file

@ -0,0 +1,211 @@
/**
* 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
};