From e70840552da8c12bcb343a7ef1595c202e1dd5c1 Mon Sep 17 00:00:00 2001 From: Seton Carmichael Date: Mon, 24 Aug 2026 08:41:05 -0400 Subject: [PATCH] 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. --- .env.example | 25 ++- CHANGELOG.md | 31 +++ COMMIT_NOTE_DRAFT.md | 36 ++++ README.md | 40 +++- auth/tools.js | 44 +++- config.js | 39 +++- email/folder-utils.js | 133 +++++++----- email/index.js | 36 +++- email/list.js | 55 +++-- email/read-multiple.js | 64 +++--- email/read.js | 54 ++--- email/search.js | 23 +- email/send.js | 133 ++++++++---- folder/create.js | 74 ++++--- folder/index.js | 20 +- folder/list.js | 62 +++--- folder/move.js | 96 ++++----- index.js | 53 +++-- mailbox/index.js | 11 + mailbox/list.js | 359 ++++++++++++++++++++++++++++++++ package.json | 2 +- tests/mailbox-path.test.js | 129 ++++++++++++ tests/mailbox-send-path.test.js | 33 +++ tools/get-email-thread.js | 56 +++-- utils/graph-api.js | 27 ++- utils/mailbox.js | 211 +++++++++++++++++++ 26 files changed, 1447 insertions(+), 399 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 COMMIT_NOTE_DRAFT.md create mode 100644 mailbox/index.js create mode 100644 mailbox/list.js create mode 100644 tests/mailbox-path.test.js create mode 100644 tests/mailbox-send-path.test.js create mode 100644 utils/mailbox.js diff --git a/.env.example b/.env.example index cac835d..fd6b5cd 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,28 @@ USE_TEST_MODE=false # Optional: Enable verbose debug logging 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' -# Defaults to Eastern Standard Time if not set. +# Defaults to America/New_York if not set. # MS_TIMEZONE=America/New_York + +# Optional: shared / delegated mailbox support (default true). +# Set false on personal-account instances if shared scopes are unwanted. +# OUTLOOK_ENABLE_SHARED_MAILBOXES=true + +# Optional: comma-separated seed list of shared mailbox UPNs/SMTPs for list-mailboxes +# OUTLOOK_SHARED_MAILBOXES=helpdesk@contoso.com,it@contoso.com + +# Optional: override probe cache path (default: ${OUTLOOK_TOKEN_STORE_PATH}.mailboxes.json) +# OUTLOOK_MAILBOX_CACHE_PATH= + +# Optional: parallel probe concurrency for list-mailboxes +# OUTLOOK_MAILBOX_PROBE_CONCURRENCY=4 + +# Optional: multi-instance token cache path +# OUTLOOK_TOKEN_STORE_PATH= + +# Optional: Graph throttle retry knobs +# OUTLOOK_MAX_RETRIES=3 +# OUTLOOK_BASE_RETRY_DELAY_MS=1000 +# OUTLOOK_MAX_RETRY_DELAY_MS=30000 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bad4f5a --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/COMMIT_NOTE_DRAFT.md b/COMMIT_NOTE_DRAFT.md new file mode 100644 index 0000000..9d89856 --- /dev/null +++ b/COMMIT_NOTE_DRAFT.md @@ -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 diff --git a/README.md b/README.md index 8c299fe..e2537eb 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Built for use with AI agents (Claude, Hermes, etc.) that support the MCP standar ## Features - **Email**: List, search, read, send, and reconstruct full conversation threads with quoted-reply stripping and signature deduplication +- **Shared mailboxes**: Optional `mailbox` parameter on email/folder tools; `list-mailboxes` probes seeded candidates (Graph cannot enumerate all rights) - **Calendar**: List, create, decline, cancel, and delete events - **Folders**: List (flat or hierarchical), create, and move emails between folders - **Inbox Rules**: List, create, and reorder execution priority of inbox rules @@ -71,15 +72,21 @@ This server uses MSAL **device code flow** with a **public client** app registra - `Mail.Read` - `Mail.ReadWrite` - `Mail.Send` + - `Mail.Read.Shared` (shared/delegated mailboxes — work/school only) + - `Mail.ReadWrite.Shared` + - `Mail.Send.Shared` - `User.Read` - `Calendars.Read` - `Calendars.ReadWrite` - `MailboxSettings.ReadWrite` - `offline_access` -7. 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. +After adding shared scopes to an existing deployment, users must **re-run device-code authenticate** so the access token's `scp` claim includes the new permissions. `check-auth-status` reports missing shared scopes. + ## Configuration All configuration is via environment variables: @@ -91,7 +98,36 @@ All configuration is via environment variables: | `USE_TEST_MODE` | No | `false` | Use mock data instead of real API calls | | `DEBUG_MODE` | No | `false` | Verbose logging (MSAL, API calls, working directory) | | `OUTLOOK_TOKEN_STORE_PATH` | No | `~/.outlook-mcp-tokens.json` | Token cache file path (override for multi-instance setups) | -| `MS_TIMEZONE` | No | `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 diff --git a/auth/tools.js b/auth/tools.js index 0abe786..028ec9a 100644 --- a/auth/tools.js +++ b/auth/tools.js @@ -3,12 +3,16 @@ */ const config = require('../config'); const tokenManager = require('./token-manager'); +const { analyzeTokenScopes, expectedSharedScopes } = require('../utils/mailbox'); async function handleAbout() { + const sharedLine = config.ENABLE_SHARED_MAILBOXES + ? 'Shared mailbox support: enabled (optional mailbox param + list-mailboxes). Requires Mail.*.Shared scopes and re-auth after upgrade.' + : 'Shared mailbox support: disabled (OUTLOOK_ENABLE_SHARED_MAILBOXES=false).'; return { content: [{ type: "text", - text: `Outlook Assistant MCP Server v${config.SERVER_VERSION}\n\nProvides access to Microsoft Outlook email, calendar, 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}`); }); + const scopeHint = config.ENABLE_SHARED_MAILBOXES + ? `\nThis login requests shared-mailbox scopes: ${expectedSharedScopes().join(', ')}.\nAfter signing in, call check-auth-status and confirm those scopes are present.` + : ''; + return { content: [{ type: "text", @@ -58,8 +66,9 @@ async function handleAuthenticate(args) { ` 2. Enter code: ${userCode}`, ``, `You have ${minutesRemaining} minutes to complete sign-in.`, - `After signing in, call check-auth-status to confirm.` - ].join('\n') + `After signing in, call check-auth-status to confirm.`, + scopeHint + ].filter(Boolean).join('\n') }] }; } @@ -73,7 +82,30 @@ async function handleCheckAuthStatus() { return { content: [{ type: "text", text: "Not authenticated" }] }; } 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) { console.error('[CHECK-AUTH-STATUS] Error:', e.message); return { content: [{ type: "text", text: "Not authenticated" }] }; @@ -89,7 +121,7 @@ const authTools = [ }, { name: "authenticate", - description: "Authenticate with Microsoft Graph API using device code flow. Returns a short code and URL. IMPORTANT: Show the user the code and URL — they must visit the URL on any device and enter the code to complete sign-in. After they sign in, call check-auth-status to confirm.", + 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: { type: "object", properties: { @@ -104,7 +136,7 @@ const authTools = [ }, { 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: [] }, handler: handleCheckAuthStatus } diff --git a/config.js b/config.js index 9d5c065..6aeab74 100644 --- a/config.js +++ b/config.js @@ -7,39 +7,59 @@ const os = require('os'); // Ensure we have a home directory path even if process.env.HOME is undefined const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir() || '/tmp'; +const enableSharedMailboxes = process.env.OUTLOOK_ENABLE_SHARED_MAILBOXES !== 'false'; + +const baseScopes = [ + 'Mail.Read', + 'Mail.ReadWrite', + 'Mail.Send', + 'User.Read', + 'Calendars.Read', + 'Calendars.ReadWrite', + 'MailboxSettings.ReadWrite', + 'offline_access' +]; + +const sharedScopes = enableSharedMailboxes + ? ['Mail.Read.Shared', 'Mail.ReadWrite.Shared', 'Mail.Send.Shared'] + : []; + module.exports = { // Server information SERVER_NAME: "outlook-assistant-main", - SERVER_VERSION: "1.0.1", - + SERVER_VERSION: "1.1.0", + // Test mode setting USE_TEST_MODE: process.env.USE_TEST_MODE === 'true', // Debug mode setting DEBUG_MODE: process.env.DEBUG_MODE === 'true', - + + // Shared / delegated mailbox feature flag (scopes + list-mailboxes tool) + ENABLE_SHARED_MAILBOXES: enableSharedMailboxes, + // Authentication configuration AUTH_CONFIG: { clientId: process.env.MS_CLIENT_ID || '', // Optional: set MS_CLIENT_SECRET for confidential client app registrations. // Public client apps (Allow public client flows enabled, no secret) leave this blank. clientSecret: process.env.MS_CLIENT_SECRET || '', - scopes: ['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'), // Device code flow: polling interval in seconds (Microsoft returns the recommended interval) deviceCodePollingInterval: 5 }, - + // Microsoft Graph API GRAPH_API_ENDPOINT: 'https://graph.microsoft.com/v1.0/', - + // Calendar constants CALENDAR_SELECT_FIELDS: 'id,subject,bodyPreview,start,end,location,organizer,attendees,isAllDay,isCancelled,recurrence', // Email constants 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', - + // 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. 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. MAX_RETRIES: parseInt(process.env.OUTLOOK_MAX_RETRIES, 10) || 3, BASE_RETRY_DELAY_MS: parseInt(process.env.OUTLOOK_BASE_RETRY_DELAY_MS, 10) || 1000, - MAX_RETRY_DELAY_MS: parseInt(process.env.OUTLOOK_MAX_RETRY_DELAY_MS, 10) || 30000 + 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 }; diff --git a/email/folder-utils.js b/email/folder-utils.js index 946232d..c39e986 100644 --- a/email/folder-utils.js +++ b/email/folder-utils.js @@ -2,6 +2,7 @@ * Email folder utilities */ const { callGraphAPI } = require('../utils/graph-api'); +const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox'); /** * Cache of folder information to reduce API calls @@ -13,46 +14,54 @@ const folderCache = {}; * Resolve a folder name to its endpoint path * @param {string} accessToken - Access token * @param {string} folderName - Folder name to resolve + * @param {string|object|null} mailbox - Optional shared mailbox UPN/SMTP or normalizeMailbox ctx * @returns {Promise} - 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 if (!folderName) { - return 'me/messages'; + return buildPath(mb, 'messages'); } - - // Handle well-known folder names - const wellKnownFolders = { - 'inbox': 'me/messages', - 'drafts': 'me/mailFolders/drafts/messages', - 'sent': 'me/mailFolders/sentItems/messages', - 'deleted': 'me/mailFolders/deletedItems/messages', - 'junk': 'me/mailFolders/junkemail/messages', - 'archive': 'me/mailFolders/archive/messages' + + // Handle well-known folder names (case-insensitive) + // Note: 'inbox' uses the messages shortcut; SentItems aliases included for search UX + const wellKnownRelative = { + 'inbox': 'messages', + 'drafts': 'mailFolders/drafts/messages', + 'sent': 'mailFolders/sentItems/messages', + 'sentitems': 'mailFolders/sentItems/messages', + 'deleted': 'mailFolders/deletedItems/messages', + 'deleteditems': 'mailFolders/deletedItems/messages', + 'junk': 'mailFolders/junkemail/messages', + 'junkemail': 'mailFolders/junkemail/messages', + 'archive': 'mailFolders/archive/messages' }; - - // Check if it's a well-known folder (case-insensitive) + const lowerFolderName = folderName.toLowerCase(); - if (wellKnownFolders[lowerFolderName]) { - console.error(`Using well-known folder path for "${folderName}"`); - return wellKnownFolders[lowerFolderName]; + if (wellKnownRelative[lowerFolderName]) { + const p = buildPath(mb, wellKnownRelative[lowerFolderName]); + console.error(`Using well-known folder path for "${folderName}": ${p}`); + return p; } - + try { - // Try to find the folder by name - const folderId = await getFolderIdByName(accessToken, folderName); + const folderId = await getFolderIdByName(accessToken, folderName, mb); if (folderId) { - const path = `me/mailFolders/${folderId}/messages`; - console.error(`Resolved folder "${folderName}" to path: ${path}`); - return path; + const p = buildPath(mb, `mailFolders/${folderId}/messages`); + console.error(`Resolved folder "${folderName}" to path: ${p}`); + return p; } - - // If not found, fall back to inbox + console.error(`Couldn't find folder "${folderName}", falling back to inbox`); - return 'me/messages'; + return buildPath(mb, 'messages'); } catch (error) { 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 * @param {string} accessToken - Access token * @param {string} folderName - Name of the folder to find + * @param {string|object|null} mailbox * @returns {Promise} - 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 { - // First try with exact match filter - console.error(`Looking for folder with name "${folderName}"`); + console.error(`Looking for folder with name "${folderName}" in ${mb.graphRoot}`); const response = await callGraphAPI( accessToken, 'GET', - 'me/mailFolders', + buildPath(mb, 'mailFolders'), null, - { $filter: `displayName eq '${folderName}'` } + { $filter: `displayName eq '${folderName}'` }, + opts ); - + if (response.value && response.value.length > 0) { console.error(`Found folder "${folderName}" with ID: ${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`); const allFoldersResponse = await callGraphAPI( accessToken, 'GET', - 'me/mailFolders', + buildPath(mb, 'mailFolders'), null, - { $top: 100 } + { $top: 100 }, + opts ); - + if (allFoldersResponse.value) { const lowerFolderName = folderName.toLowerCase(); const matchingFolder = allFoldersResponse.value.find( folder => folder.displayName.toLowerCase() === lowerFolderName ); - + if (matchingFolder) { console.error(`Found case-insensitive match for "${folderName}" with ID: ${matchingFolder.id}`); return matchingFolder.id; } } - + console.error(`No folder found matching "${folderName}"`); return null; } catch (error) { @@ -112,51 +127,56 @@ async function getFolderIdByName(accessToken, folderName) { /** * Get all mail folders * @param {string} accessToken - Access token + * @param {string|object|null} mailbox * @returns {Promise} - 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 { - // Get top-level folders const response = await callGraphAPI( accessToken, 'GET', - 'me/mailFolders', + buildPath(mb, 'mailFolders'), null, - { + { $top: 100, $select: 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount' - } + }, + opts ); - + if (!response.value) { return []; } - - // Get child folders for folders with children + const foldersWithChildren = response.value.filter(f => f.childFolderCount > 0); - + const childFolderPromises = foldersWithChildren.map(async (folder) => { try { const childResponse = await callGraphAPI( accessToken, 'GET', - `me/mailFolders/${folder.id}/childFolders`, + buildPath(mb, `mailFolders/${folder.id}/childFolders`), null, - { + { $select: 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount' - } + }, + opts ); - + return childResponse.value || []; } catch (error) { console.error(`Error getting child folders for "${folder.displayName}": ${error.message}`); return []; } }); - + const childFolders = await Promise.all(childFolderPromises); - - // Combine top-level folders and all child folders + return [...response.value, ...childFolders.flat()]; } catch (error) { console.error(`Error getting all folders: ${error.message}`); @@ -167,5 +187,6 @@ async function getAllFolders(accessToken) { module.exports = { resolveFolderPath, getFolderIdByName, - getAllFolders + getAllFolders, + folderCache }; diff --git a/email/index.js b/email/index.js index 1033637..98288fc 100644 --- a/email/index.js +++ b/email/index.js @@ -7,11 +7,16 @@ const handleReadEmail = require('./read'); const handleReadMultipleEmails = require('./read-multiple'); const handleSendEmail = require('./send'); +const mailboxProp = { + type: "string", + description: "Optional shared/delegated mailbox UPN or SMTP (e.g. 'helpdesk@contoso.com'). Omit for the signed-in user's primary mailbox. Message IDs are mailbox-scoped — pass the same mailbox on read/thread calls." +}; + // Email tool definitions const emailTools = [ { name: "list-emails", - description: "Lists recent emails from a folder. Results include 'conversationId' which can be passed to 'get-email-thread' to retrieve a full thread.", + 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: { type: "object", properties: { @@ -34,7 +39,8 @@ const emailTools = [ dateRange: { type: "string", description: "Predefined date range ('today', 'yesterday', 'last7days', 'last30days', 'thisweek', 'lastweek', 'thismonth', 'lastmonth')" - } + }, + mailbox: mailboxProp }, required: [] }, @@ -42,7 +48,7 @@ const emailTools = [ }, { name: "search-emails", - description: "Search for emails by sender, subject, keywords, or filters. Results include 'conversationId' — pass it to 'get-email-thread' to read the full thread. 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: { type: "object", properties: { @@ -93,7 +99,8 @@ const emailTools = [ strict: { anyOf: [{ type: "boolean" }, { type: "string" }], description: "Set true to disable the fallback to recent emails when no exact search matches are found" - } + }, + mailbox: mailboxProp }, required: [] }, @@ -101,14 +108,15 @@ const emailTools = [ }, { 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: { type: "object", properties: { id: { type: "string", description: "ID of the email to read" - } + }, + mailbox: mailboxProp }, required: ["id"] }, @@ -116,7 +124,7 @@ const emailTools = [ }, { 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: { type: "object", properties: { @@ -126,7 +134,8 @@ const emailTools = [ type: "string" }, description: "Array of email IDs to read (max: 10)" - } + }, + mailbox: mailboxProp }, required: ["ids"] }, @@ -134,7 +143,7 @@ const emailTools = [ }, { 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: { type: "object", properties: { @@ -166,6 +175,15 @@ const emailTools = [ saveToSentItems: { type: "boolean", description: "Whether to save the email to sent items" + }, + mailbox: mailboxProp, + from: { + type: "string", + description: "Optional From SMTP. Defaults to mailbox when mailbox is set." + }, + onBehalfOf: { + anyOf: [{ type: "boolean" }, { type: "string" }], + description: "If true, send via me/sendMail with from=shared (Send on Behalf style). Default false uses users/{mailbox}/sendMail when mailbox is set." } }, required: ["to", "subject", "body"] diff --git a/email/list.js b/email/list.js index 7c129a6..c8628de 100644 --- a/email/list.js +++ b/email/list.js @@ -8,6 +8,7 @@ const { callGraphAPI } = require('../utils/graph-api'); const { ensureAuthenticated } = require('../auth'); const { buildODataFilter, buildDateFilter } = require('../utils/odata-helpers'); const { resolveFolderPath } = require('./folder-utils'); +const { normalizeMailbox, withMailboxHeaders } = require('../utils/mailbox'); /** * List emails handler @@ -17,40 +18,36 @@ const { resolveFolderPath } = require('./folder-utils'); async function handleListEmails(args) { const folder = args.folder || "inbox"; const count = Math.min(parseInt(args.count, 10) || 10, config.MAX_RESULT_COUNT); - + const mb = normalizeMailbox(args.mailbox); + try { - // Get access token const accessToken = await ensureAuthenticated(); - - // Resolve folder path using the proper folder utilities - const endpoint = await resolveFolderPath(accessToken, folder); - - // Add query parameters + const endpoint = await resolveFolderPath(accessToken, folder, mb); + const opts = { headers: withMailboxHeaders(mb) }; + const queryParams = { $top: count, $orderby: 'receivedDateTime desc', $select: config.EMAIL_SELECT_FIELDS }; - - // Add date filtering if specified + const dateConditions = buildDateFilter(args.dateFrom, args.dateTo, args.dateRange); if (dateConditions.length > 0) { queryParams.$filter = buildODataFilter(dateConditions); } - - // Make API call - const response = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams); - + + const response = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams, opts); + if (!response.value || response.value.length === 0) { + const where = mb.kind === 'user' ? `${folder} (${mb.smtpOrUpn})` : folder; return { - content: [{ - type: "text", - text: `No emails found in ${folder}.` + content: [{ + type: "text", + text: `No emails found in ${where}.` }] }; } - - // Format results + const emailList = response.value.map((email, index) => { const sender = email.from ? email.from.emailAddress : { name: 'Unknown', address: 'unknown' }; const date = formatDateTime(email.receivedDateTime); @@ -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}`; }).join("\n"); - - // Build result message with date filter info + let resultMessage = `Found ${response.value.length} emails in ${folder}`; - + if (mb.kind === 'user') { + resultMessage += ` [mailbox: ${mb.smtpOrUpn}]`; + } + if (args.dateRange) { resultMessage += ` (${args.dateRange})`; } else if (args.dateFrom || args.dateTo) { @@ -71,9 +70,9 @@ async function handleListEmails(args) { if (args.dateTo) dateInfo.push(`to: ${args.dateTo}`); resultMessage += ` (${dateInfo.join(', ')})`; } - + resultMessage += `:\n\n${emailList}`; - + return { content: [{ type: "text", @@ -83,16 +82,16 @@ async function handleListEmails(args) { } catch (error) { if (error.message === 'Authentication required') { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Authentication required. Please use the 'authenticate' tool first." }] }; } - + return { - content: [{ - type: "text", + content: [{ + type: "text", text: `Error listing emails: ${error.message}` }] }; diff --git a/email/read-multiple.js b/email/read-multiple.js index 2cfcd4a..40531f9 100644 --- a/email/read-multiple.js +++ b/email/read-multiple.js @@ -6,6 +6,7 @@ const { callGraphAPI } = require('../utils/graph-api'); const { ensureAuthenticated } = require('../auth'); const { cleanBody } = require('../utils/bodyParser'); const { formatDateTime } = require('../utils/time-formatter'); +const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox'); /** * Format a single email for display @@ -19,22 +20,19 @@ function formatEmail(email, emailId) { } try { - // Format sender, recipients, etc. const sender = email.from ? `${email.from.emailAddress.name} (${email.from.emailAddress.address})` : 'Unknown'; const to = email.toRecipients ? email.toRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None'; const cc = email.ccRecipients && email.ccRecipients.length > 0 ? email.ccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None'; const bcc = email.bccRecipients && email.bccRecipients.length > 0 ? email.bccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None'; const date = formatDateTime(email.receivedDateTime); - - // Extract and clean body content (cleanBody handles both HTML and plain text) + let body = ''; if (email.body) { body = cleanBody(email.body.content); } else { body = cleanBody(email.bodyPreview) || 'No content'; } - - // Format the email + return `From: ${sender} To: ${to} ${cc !== 'None' ? `CC: ${cc}\n` : ''}${bcc !== 'None' ? `BCC: ${bcc}\n` : ''}Subject: ${email.subject} @@ -55,55 +53,54 @@ ${body}`; */ async function handleReadMultipleEmails(args) { const emailIds = args.ids; - + const mb = normalizeMailbox(args.mailbox); + const opts = { headers: withMailboxHeaders(mb) }; + if (!emailIds || !Array.isArray(emailIds) || emailIds.length === 0) { return { - content: [{ - type: "text", + content: [{ + type: "text", 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; if (emailIds.length > maxEmails) { return { - content: [{ - type: "text", + content: [{ + type: "text", text: `Too many email IDs provided. Maximum allowed is ${maxEmails}, but ${emailIds.length} were provided.` }] }; } - + try { - // Get access token const accessToken = await ensureAuthenticated(); - - // Create concurrent API calls for all email IDs + const emailPromises = emailIds.map(async (emailId) => { try { - const endpoint = `me/messages/${encodeURIComponent(emailId)}`; + const endpoint = buildPath(mb, `messages/${emailId}`); const queryParams = { $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 }; } catch (error) { console.error(`Error reading email ${emailId}: ${error.message}`); return { emailId, email: null, error: error.message }; } }); - - // Wait for all API calls to complete + const results = await Promise.all(emailPromises); - - // Format all emails + + const mailboxNote = mb.kind === 'user' ? ` Mailbox: ${mb.smtpOrUpn}.` : ''; + const formattedEmails = results.map((result, index) => { const emailNumber = index + 1; const separator = "=".repeat(80); - + if (result.error) { return `${separator} EMAIL ${emailNumber} (ID: ${result.emailId}) @@ -117,15 +114,14 @@ ${separator} ${formattedEmail}`; } }); - - // Count successful vs failed reads + const successCount = results.filter(r => !r.error && r.email).length; const errorCount = results.filter(r => r.error || !r.email).length; - - const summary = `Retrieved ${successCount} email(s) successfully${errorCount > 0 ? `, ${errorCount} failed` : ''}. + + const summary = `Retrieved ${successCount} email(s) successfully${errorCount > 0 ? `, ${errorCount} failed` : ''}.${mailboxNote} `; - + return { content: [ { @@ -137,20 +133,20 @@ ${formattedEmail}`; } catch (error) { if (error.message === 'Authentication required') { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Authentication required. Please use the 'authenticate' tool first." }] }; } - + return { - content: [{ - type: "text", + content: [{ + type: "text", text: `Error accessing emails: ${error.message}` }] }; } } -module.exports = handleReadMultipleEmails; \ No newline at end of file +module.exports = handleReadMultipleEmails; diff --git a/email/read.js b/email/read.js index 38d9959..b2582d9 100644 --- a/email/read.js +++ b/email/read.js @@ -6,6 +6,7 @@ const { callGraphAPI } = require('../utils/graph-api'); const { ensureAuthenticated } = require('../auth'); const { cleanBody } = require('../utils/bodyParser'); const { formatDateTime } = require('../utils/time-formatter'); +const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox'); /** * Read email handler @@ -14,29 +15,30 @@ const { formatDateTime } = require('../utils/time-formatter'); */ async function handleReadEmail(args) { const emailId = args.id; - + const mb = normalizeMailbox(args.mailbox); + if (!emailId) { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Email ID is required." }] }; } - + try { - // Get access token const accessToken = await ensureAuthenticated(); - - // Make API call to get email details - const endpoint = `me/messages/${encodeURIComponent(emailId)}`; + + // Do not pre-encode the ID — callGraphAPI encodes each path segment once. + const endpoint = buildPath(mb, `messages/${emailId}`); const queryParams = { $select: config.EMAIL_DETAIL_FIELDS }; - + const opts = { headers: withMailboxHeaders(mb) }; + try { - const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams); - + const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams, opts); + if (!email) { return { 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 to = email.toRecipients ? email.toRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None'; const cc = email.ccRecipients && email.ccRecipients.length > 0 ? email.ccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None'; const bcc = email.bccRecipients && email.bccRecipients.length > 0 ? email.bccRecipients.map(r => `${r.emailAddress.name} (${r.emailAddress.address})`).join(", ") : 'None'; const date = formatDateTime(email.receivedDateTime); - - // Extract and clean body content (cleanBody handles both HTML and plain text) + let body = ''; if (email.body) { body = cleanBody(email.body.content); } else { body = cleanBody(email.bodyPreview) || 'No content'; } - - // Format the email - const formattedEmail = `From: ${sender} + + const mailboxLine = mb.kind === 'user' ? `Mailbox: ${mb.smtpOrUpn}\n` : ''; + + const formattedEmail = `${mailboxLine}From: ${sender} To: ${to} ${cc !== 'None' ? `CC: ${cc}\n` : ''}${bcc !== 'None' ? `BCC: ${bcc}\n` : ''}Subject: ${email.subject} Date: ${date} @@ -72,7 +73,7 @@ Importance: ${email.importance || 'normal'} Has Attachments: ${email.hasAttachments ? 'Yes' : 'No'} ${body}`; - + return { content: [ { @@ -83,14 +84,13 @@ ${body}`; }; } catch (error) { console.error(`Error reading email: ${error.message}`); - - // Improved error handling with more specific messages + if (error.message.includes("doesn't belong to the targeted mailbox")) { return { content: [ { type: "text", - text: `The email ID seems invalid or doesn't belong to 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) { if (error.message === 'Authentication required') { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Authentication required. Please use the 'authenticate' tool first." }] }; } - + return { - content: [{ - type: "text", + content: [{ + type: "text", text: `Error accessing email: ${error.message}` }] }; diff --git a/email/search.js b/email/search.js index 2c382d9..ebd5658 100644 --- a/email/search.js +++ b/email/search.js @@ -7,6 +7,7 @@ const { ensureAuthenticated } = require('../auth'); const { resolveFolderPath } = require('./folder-utils'); const { formatDateTime } = require('../utils/time-formatter'); const { buildODataFilter, buildDateFilter } = require('../utils/odata-helpers'); +const { normalizeMailbox, withMailboxHeaders } = require('../utils/mailbox'); /** * Search emails handler @@ -25,6 +26,8 @@ async function handleSearchEmails(args) { const hasAttachments = args.hasAttachments === true || args.hasAttachments === 'true' ? true : undefined; const unreadOnly = args.unreadOnly === true || args.unreadOnly === 'true' ? true : undefined; const strict = args.strict === true || args.strict === 'true'; + const mb = normalizeMailbox(args.mailbox); + const graphOpts = { headers: withMailboxHeaders(mb) }; // Date filtering uses the same timezone-aware helpers as list-emails. const dateFrom = args.dateFrom || ''; @@ -36,8 +39,8 @@ async function handleSearchEmails(args) { const accessToken = await ensureAuthenticated(); // Resolve the folder path - const endpoint = await resolveFolderPath(accessToken, folder); - console.error(`Using endpoint: ${endpoint} for folder: ${folder}`); + const endpoint = await resolveFolderPath(accessToken, folder, mb); + console.error(`Using endpoint: ${endpoint} for folder: ${folder} mailbox=${mb.graphRoot}`); // Execute progressive search const response = await progressiveSearch( @@ -47,10 +50,11 @@ async function handleSearchEmails(args) { { hasAttachments, unreadOnly }, count, 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) { // Handle authentication errors if (error.message === 'Authentication required') { @@ -87,7 +91,7 @@ async function handleSearchEmails(args) { * 3. Only boolean filters → $filter + $orderby (fully supported) * 4. Fallback → recent emails (only when not in strict mode; marked clearly) */ -async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms, count, strict = false, dateOpts = {}) { +async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms, count, strict = false, dateOpts = {}, graphOpts = {}) { const hasTextTerms = !!(searchTerms.query || searchTerms.from || searchTerms.to || searchTerms.subject); const hasBooleanFilters = filterTerms.hasAttachments === true || filterTerms.unreadOnly === true; const hasDateFilters = !!(dateOpts.dateFrom || dateOpts.dateTo || dateOpts.dateRange); @@ -133,7 +137,7 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms }; 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) { 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}`); - 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) { 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}`); - 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`); return response; } catch (error) { @@ -219,7 +223,7 @@ async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms 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`); if (dateFilterString) { @@ -318,6 +322,7 @@ function formatSearchResults(response, dateOpts = {}) { if (dateOpts.dateRange) dateParts.push(`dateRange: ${dateOpts.dateRange}`); if (dateOpts.dateFrom) dateParts.push(`from: ${dateOpts.dateFrom}`); if (dateOpts.dateTo) dateParts.push(`to: ${dateOpts.dateTo}`); + if (dateOpts.mailbox) dateParts.push(`mailbox: ${dateOpts.mailbox}`); const dateInfo = dateParts.length > 0 ? ` (${dateParts.join(', ')})` : ''; return { diff --git a/email/send.js b/email/send.js index a8d65fc..4b744a7 100644 --- a/email/send.js +++ b/email/send.js @@ -4,6 +4,7 @@ const config = require('../config'); const { callGraphAPI } = require('../utils/graph-api'); const { ensureAuthenticated } = require('../auth'); +const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox'); /** * Send email handler @@ -11,41 +12,51 @@ const { ensureAuthenticated } = require('../auth'); * @returns {object} - MCP response */ async function handleSendEmail(args) { - const { to, cc, bcc, subject, body, importance = 'normal', saveToSentItems = true } = args; - - // Validate required parameters + const { + to, + cc, + bcc, + subject, + body, + importance = 'normal', + saveToSentItems = true, + from: fromArg, + onBehalfOf = false + } = args; + + const mb = normalizeMailbox(args.mailbox); + const onBehalf = onBehalfOf === true || onBehalfOf === 'true'; + if (!to) { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Recipient (to) is required." }] }; } - + if (!subject) { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Subject is required." }] }; } - + if (!body) { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Body content is required." }] }; } - + try { - // Get access token const accessToken = await ensureAuthenticated(); - - // Format recipients + const toRecipients = to.split(',').map(email => { email = email.trim(); return { @@ -54,7 +65,7 @@ async function handleSendEmail(args) { } }; }); - + const ccRecipients = cc ? cc.split(',').map(email => { email = email.trim(); return { @@ -63,7 +74,7 @@ async function handleSendEmail(args) { } }; }) : []; - + const bccRecipients = bcc ? bcc.split(',').map(email => { email = email.trim(); return { @@ -72,45 +83,83 @@ async function handleSendEmail(args) { } }; }) : []; - - // Prepare email object - const emailObject = { - message: { - subject, - body: { - contentType: body.includes(' 0 ? ccRecipients : undefined, - bccRecipients: bccRecipients.length > 0 ? bccRecipients : undefined, - importance + + const fromAddr = (fromArg && String(fromArg).trim()) + || (mb.kind === 'user' ? mb.smtpOrUpn : null); + + // Default: when mailbox is a shared UPN, send via users/{upn}/sendMail (Send As style). + // onBehalfOf=true forces me/sendMail with from=shared (Send on Behalf style). + let sendPath = 'me/sendMail'; + let anchorMb = mb; + if (mb.kind === 'user' && !onBehalf) { + sendPath = buildPath(mb, 'sendMail'); + } else if (fromAddr && onBehalf) { + sendPath = 'me/sendMail'; + anchorMb = normalizeMailbox(fromAddr); + } else if (mb.kind === 'user') { + sendPath = buildPath(mb, 'sendMail'); + } + + const message = { + subject, + body: { + // New Outlook + Graph: prefer HTML when the body looks like a document. + // Match ]|<(?: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 }; - - // Make API call to send email - await callGraphAPI(accessToken, 'POST', 'me/sendMail', emailObject); - + + await callGraphAPI( + accessToken, + 'POST', + sendPath, + emailObject, + null, + { headers: withMailboxHeaders(anchorMb) } + ); + + const fromNote = fromAddr ? `\nFrom: ${fromAddr}${onBehalf ? ' (on behalf)' : ''}` : ''; + const mailboxNote = mb.kind === 'user' ? `\nMailbox: ${mb.smtpOrUpn}` : ''; + return { - content: [{ - 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` + content: [{ + type: "text", + text: `Email sent successfully!${mailboxNote}${fromNote}\n\nSubject: ${subject}\nRecipients: ${toRecipients.length}${ccRecipients.length > 0 ? ` + ${ccRecipients.length} CC` : ''}${bccRecipients.length > 0 ? ` + ${bccRecipients.length} BCC` : ''}\nMessage Length: ${body.length} characters` }] }; } catch (error) { if (error.message === 'Authentication required') { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Authentication required. Please use the 'authenticate' tool first." }] }; } - + return { - content: [{ - type: "text", + content: [{ + type: "text", text: `Error sending email: ${error.message}` }] }; diff --git a/folder/create.js b/folder/create.js index b29e477..16cd0f8 100644 --- a/folder/create.js +++ b/folder/create.js @@ -4,6 +4,7 @@ const { callGraphAPI } = require('../utils/graph-api'); const { ensureAuthenticated } = require('../auth'); const { getFolderIdByName } = require('../email/folder-utils'); +const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox'); /** * Create folder handler @@ -13,42 +14,40 @@ const { getFolderIdByName } = require('../email/folder-utils'); async function handleCreateFolder(args) { const folderName = args.name; const parentFolder = args.parentFolder || ''; - + const mb = normalizeMailbox(args.mailbox); + if (!folderName) { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Folder name is required." }] }; } - + try { - // Get access token const accessToken = await ensureAuthenticated(); - - // Create folder with appropriate parent - const result = await createMailFolder(accessToken, folderName, parentFolder); - + const result = await createMailFolder(accessToken, folderName, parentFolder, mb); + return { - content: [{ - type: "text", + content: [{ + type: "text", text: result.message }] }; } catch (error) { if (error.message === 'Authentication required') { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Authentication required. Please use the 'authenticate' tool first." }] }; } - + return { - content: [{ - type: "text", + content: [{ + type: "text", text: `Error creating folder: ${error.message}` }] }; @@ -57,56 +56,53 @@ async function handleCreateFolder(args) { /** * Create a new mail folder - * @param {string} accessToken - Access token - * @param {string} folderName - Name of the folder to create - * @param {string} parentFolderName - Name of the parent folder (optional) - * @returns {Promise} - 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 { - // Check if a folder with this name already exists - const existingFolder = await getFolderIdByName(accessToken, folderName); + const existingFolder = await getFolderIdByName(accessToken, folderName, mb); if (existingFolder) { return { 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 = 'me/mailFolders'; + + let endpoint = buildPath(mb, 'mailFolders'); if (parentFolderName) { - const parentId = await getFolderIdByName(accessToken, parentFolderName); + const parentId = await getFolderIdByName(accessToken, parentFolderName, mb); if (!parentId) { return { success: false, 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 = { displayName: folderName }; - + const response = await callGraphAPI( accessToken, 'POST', endpoint, - folderData + folderData, + null, + opts ); - + if (response && response.id) { - const locationInfo = parentFolderName - ? `inside "${parentFolderName}"` + const locationInfo = parentFolderName + ? `inside "${parentFolderName}"` : "at the root level"; - + const mailboxInfo = mb.kind === 'user' ? ` (mailbox: ${mb.smtpOrUpn})` : ''; + return { success: true, - message: `Successfully created folder "${folderName}" ${locationInfo}.`, + message: `Successfully created folder "${folderName}" ${locationInfo}${mailboxInfo}.`, folderId: response.id }; } else { diff --git a/folder/index.js b/folder/index.js index b4f3cda..a4e4651 100644 --- a/folder/index.js +++ b/folder/index.js @@ -5,11 +5,16 @@ const { handleListFolders } = require('./list'); const handleCreateFolder = require('./create'); const handleMoveEmails = require('./move'); +const mailboxProp = { + type: "string", + description: "Optional shared/delegated mailbox UPN or SMTP. Omit for the signed-in user's primary mailbox." +}; + // Folder management tool definitions const folderTools = [ { name: "list-folders", - description: "Lists mail folders in your Outlook account", + description: "Lists mail folders in your Outlook account (or a shared mailbox when mailbox is set)", inputSchema: { type: "object", properties: { @@ -20,7 +25,8 @@ const folderTools = [ includeChildren: { anyOf: [{ type: "boolean" }, { type: "string" }], description: "Include child folders in hierarchy" - } + }, + mailbox: mailboxProp }, required: [] }, @@ -28,7 +34,7 @@ const folderTools = [ }, { 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: { type: "object", properties: { @@ -39,7 +45,8 @@ const folderTools = [ parentFolder: { type: "string", description: "Optional parent folder name (default is root)" - } + }, + mailbox: mailboxProp }, required: ["name"] }, @@ -47,7 +54,7 @@ const folderTools = [ }, { name: "move-emails", - description: "Moves emails from one folder to another", + description: "Moves emails from one folder to another within the same mailbox", inputSchema: { type: "object", properties: { @@ -62,7 +69,8 @@ const folderTools = [ sourceFolder: { type: "string", description: "Optional name of the source folder (default is inbox)" - } + }, + mailbox: mailboxProp }, required: ["emailIds", "targetFolder"] }, diff --git a/folder/list.js b/folder/list.js index f5ee498..b93fd0c 100644 --- a/folder/list.js +++ b/folder/list.js @@ -3,6 +3,7 @@ */ const { callGraphAPI } = require('../utils/graph-api'); const { ensureAuthenticated } = require('../auth'); +const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox'); /** * List folders handler @@ -12,44 +13,47 @@ const { ensureAuthenticated } = require('../auth'); async function handleListFolders(args) { const includeItemCounts = args.includeItemCounts === true || args.includeItemCounts === 'true'; const includeChildren = args.includeChildren === true || args.includeChildren === 'true'; - + const mb = normalizeMailbox(args.mailbox); + try { // Get access token const accessToken = await ensureAuthenticated(); - + // Get all mail folders - const folders = await getAllFoldersHierarchy(accessToken, includeItemCounts); - + const folders = await getAllFoldersHierarchy(accessToken, includeItemCounts, mb); + + const mailboxPrefix = mb.kind === 'user' ? `Mailbox: ${mb.smtpOrUpn}\n\n` : ''; + // If including children, format as hierarchy if (includeChildren) { return { - content: [{ - type: "text", - text: formatFolderHierarchy(folders, includeItemCounts) + content: [{ + type: "text", + text: mailboxPrefix + formatFolderHierarchy(folders, includeItemCounts) }] }; } else { // Otherwise, format as flat list return { - content: [{ - type: "text", - text: formatFolderList(folders, includeItemCounts) + content: [{ + type: "text", + text: mailboxPrefix + formatFolderList(folders, includeItemCounts) }] }; } } catch (error) { if (error.message === 'Authentication required') { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Authentication required. Please use the 'authenticate' tool first." }] }; } - + return { - content: [{ - type: "text", + content: [{ + type: "text", text: `Error listing folders: ${error.message}` }] }; @@ -60,50 +64,54 @@ async function handleListFolders(args) { * Get all mail folders with hierarchy information * @param {string} accessToken - Access token * @param {boolean} includeItemCounts - Include item counts in response + * @param {object} mb - normalizeMailbox result * @returns {Promise} - Array of folder objects with hierarchy */ -async function getAllFoldersHierarchy(accessToken, includeItemCounts) { +async function getAllFoldersHierarchy(accessToken, includeItemCounts, mb = normalizeMailbox(null)) { try { + const opts = { headers: withMailboxHeaders(mb) }; // Determine select fields based on whether to include counts const selectFields = includeItemCounts ? 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount' : 'id,displayName,parentFolderId,childFolderCount'; - + // Get all mail folders const response = await callGraphAPI( accessToken, 'GET', - 'me/mailFolders', + buildPath(mb, 'mailFolders'), null, - { + { $top: 100, $select: selectFields - } + }, + opts ); - + if (!response.value) { return []; } - + // Get child folders for folders with children const foldersWithChildren = response.value.filter(f => f.childFolderCount > 0); - + const childFolderPromises = foldersWithChildren.map(async (folder) => { try { const childResponse = await callGraphAPI( accessToken, 'GET', - `me/mailFolders/${folder.id}/childFolders`, + buildPath(mb, `mailFolders/${folder.id}/childFolders`), null, - { $select: selectFields } + { $select: selectFields }, + opts ); - + // Add parent folder info to each child const childFolders = childResponse.value || []; childFolders.forEach(child => { child.parentFolder = folder.displayName; }); - + return childFolders; } catch (error) { console.error(`Error getting child folders for "${folder.displayName}": ${error.message}`); diff --git a/folder/move.js b/folder/move.js index f53864c..07f2d68 100644 --- a/folder/move.js +++ b/folder/move.js @@ -4,6 +4,7 @@ const { callGraphAPI } = require('../utils/graph-api'); const { ensureAuthenticated } = require('../auth'); const { getFolderIdByName } = require('../email/folder-utils'); +const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox'); /** * Move emails handler @@ -14,63 +15,61 @@ async function handleMoveEmails(args) { const emailIds = args.emailIds || ''; const targetFolder = args.targetFolder || ''; const sourceFolder = args.sourceFolder || ''; - + const mb = normalizeMailbox(args.mailbox); + if (!emailIds) { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Email IDs are required. Please provide a comma-separated list of email IDs to move." }] }; } - + if (!targetFolder) { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Target folder name is required." }] }; } - + try { - // Get access token const accessToken = await ensureAuthenticated(); - - // Parse email IDs + const ids = emailIds.split(',').map(id => id.trim()).filter(id => id); - + if (ids.length === 0) { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "No valid email IDs provided." }] }; } - - // Move emails - const result = await moveEmailsToFolder(accessToken, ids, targetFolder, sourceFolder); - + + const result = await moveEmailsToFolder(accessToken, ids, targetFolder, sourceFolder, mb); + return { - content: [{ - type: "text", + content: [{ + type: "text", text: result.message }] }; } catch (error) { if (error.message === 'Authentication required') { return { - content: [{ - type: "text", + content: [{ + type: "text", text: "Authentication required. Please use the 'authenticate' tool first." }] }; } - + return { - content: [{ - type: "text", + content: [{ + type: "text", text: `Error moving emails: ${error.message}` }] }; @@ -79,42 +78,36 @@ async function handleMoveEmails(args) { /** * Move emails to a folder - * @param {string} accessToken - Access token - * @param {Array} 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} - 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 { - // Get the target folder ID - const targetFolderId = await getFolderIdByName(accessToken, targetFolderName); + const targetFolderId = await getFolderIdByName(accessToken, targetFolderName, mb); if (!targetFolderId) { return { 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 = { successful: [], failed: [] }; - - // Process each email one by one to handle errors independently + for (const emailId of emailIds) { try { - // Move the email await callGraphAPI( accessToken, 'POST', - `me/messages/${emailId}/move`, + buildPath(mb, `messages/${emailId}/move`), { destinationId: targetFolderId - } + }, + null, + opts ); - + results.successful.push(emailId); } catch (error) { console.error(`Error moving email ${emailId}: ${error.message}`); @@ -124,31 +117,30 @@ async function moveEmailsToFolder(accessToken, emailIds, targetFolderName, sourc }); } } - - // Generate result message + let message = ''; - + if (results.successful.length > 0) { - message += `Successfully moved ${results.successful.length} email(s) to "${targetFolderName}".`; + message += `Successfully moved ${results.successful.length} email(s) to "${targetFolderName}"`; + if (mb.kind === 'user') message += ` (${mb.smtpOrUpn})`; + message += '.'; } - + if (results.failed.length > 0) { if (message) message += '\n\n'; message += `Failed to move ${results.failed.length} email(s). Errors:`; - - // Show first few errors with details + const maxErrors = Math.min(results.failed.length, 3); for (let i = 0; i < maxErrors; i++) { const failure = results.failed[i]; - message += `\n- Email ${i+1}: ${failure.error}`; + message += `\n- Email ${i + 1}: ${failure.error}`; } - - // If there are more errors, just mention the count + if (results.failed.length > maxErrors) { message += `\n...and ${results.failed.length - maxErrors} more.`; } } - + return { success: results.successful.length > 0, message, diff --git a/index.js b/index.js index 2925013..56c426c 100644 --- a/index.js +++ b/index.js @@ -8,43 +8,47 @@ * INSTRUCTIONS FOR AI MODELS: * This server provides comprehensive Outlook integration with the following capabilities: * - * 🔐 AUTHENTICATION (Required First): - * - Use `check-auth-status()` to verify authentication + * AUTHENTICATION (Required First): + * - Use `check-auth-status()` to verify authentication (also reports shared-mailbox scopes) * - Use `authenticate()` if not authenticated (follow the provided URL) * - * 📧 EMAIL MANAGEMENT: + * EMAIL MANAGEMENT: * - `list-emails()` - List emails with advanced date filtering. Results include conversationId. * - `search-emails({ from, subject, query, unreadOnly, hasAttachments })` - Search emails. Results include conversationId. * - `read-email({ id })` - Read full email content (body auto-cleaned) * - `read-emails({ ids: [id1, id2] })` - Read multiple emails at once (max: 10, bodies auto-cleaned) - * - `get-email-thread({ conversationId })` - Fetch a complete deduplicated thread (quoted replies stripped). 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 + * - Optional `mailbox` on email/folder/thread tools targets a shared/delegated mailbox (UPN/SMTP) + * - `list-mailboxes()` - Probe primary + seeded shared mailboxes for read access * - * 💡 RECOMMENDED EMAIL WORKFLOW: + * RECOMMENDED EMAIL WORKFLOW: * 1. search-emails() or list-emails() → get conversationId from results * 2. get-email-thread({ conversationId }) → read the full thread efficiently + * 3. For shared mailboxes: list-mailboxes() or pass mailbox: 'shared@domain' on every call (IDs are mailbox-scoped) * - * 📅 CALENDAR MANAGEMENT: + * CALENDAR MANAGEMENT: * - `list-events()` - List calendar events * - `create-event({ subject, start, end })` - Create meetings * - `decline-event()`, `cancel-event()` - Respond to invitations * - * 📁 FOLDER MANAGEMENT: + * FOLDER MANAGEMENT: * - `list-folders()` - List mail folders * - `create-folder({ name })` - Create new folders * - `move-emails({ emailIds, targetFolder })` - Organize emails * - * 📋 EMAIL RULES: + * EMAIL RULES: * - `list-rules()` - List inbox rules * - `create-rule({ name, conditions, actions })` - Automate email handling * - * 💡 KEY FEATURES: + * KEY FEATURES: * - Date filtering: Use dateRange ("today", "last7days") or dateFrom/dateTo * - High limits: Up to 500 emails/events (WARNING: may consume significant tokens) * - Comprehensive search: Filter by sender, subject, attachments, read status * - Full automation: Create rules for automatic email organization + * - Shared mailboxes: Mail.Read.Shared / Mail.ReadWrite.Shared / Mail.Send.Shared + re-auth after upgrade * - * 📖 For complete documentation, see MCP_TOOLS_GUIDE.md + * For complete documentation, see README.md */ const { Server } = require("@modelcontextprotocol/sdk/server/index.js"); const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js"); @@ -55,10 +59,12 @@ const { emailTools } = require('./email'); const { folderTools } = require('./folder'); const { rulesTools } = require('./rules'); const { threadTool } = require('./tools/get-email-thread'); +const { mailboxTools } = require('./mailbox'); // Log startup information -console.error(`STARTING ${config.SERVER_NAME.toUpperCase()} MCP SERVER`); +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(`Shared mailboxes: ${config.ENABLE_SHARED_MAILBOXES ? 'enabled' : 'disabled'}`); if (config.DEBUG_MODE) { console.error(`[DEBUG] Current Working Directory: ${process.cwd()}`); console.error(`[DEBUG] MS_CLIENT_ID: ${process.env.MS_CLIENT_ID ? 'SET' : 'NOT SET'}`); @@ -72,7 +78,8 @@ const TOOLS = [ ...emailTools, ...folderTools, ...rulesTools, - threadTool + threadTool, + ...(config.ENABLE_SHARED_MAILBOXES ? mailboxTools : []) ]; // Create server with tools capabilities @@ -93,7 +100,7 @@ server.fallbackRequestHandler = async (request) => { try { const { method, params, id } = request; console.error(`REQUEST: ${method} [${id}]`); - + // Initialize handler if (method === "initialize") { console.error(`INITIALIZE REQUEST: ID [${id}]`); @@ -108,17 +115,17 @@ server.fallbackRequestHandler = async (request) => { serverInfo: { name: config.SERVER_NAME, 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 if (method === "tools/list") { console.error(`TOOLS LIST REQUEST: ID [${id}]`); console.error(`TOOLS COUNT: ${TOOLS.length}`); console.error(`TOOLS NAMES: ${TOOLS.map(t => t.name).join(', ')}`); - + return { tools: TOOLS.map(tool => ({ name: tool.name, @@ -127,25 +134,25 @@ server.fallbackRequestHandler = async (request) => { })) }; } - + // Required empty responses for other capabilities if (method === "resources/list") return { resources: [] }; if (method === "prompts/list") return { prompts: [] }; - + // Tool call handler if (method === "tools/call") { try { const { name, arguments: args = {} } = params || {}; - + console.error(`TOOL CALL: ${name}`); - + // Find the tool handler const tool = TOOLS.find(t => t.name === name); - + if (tool && tool.handler) { return await tool.handler(args); } - + // Tool not found return { error: { @@ -163,7 +170,7 @@ server.fallbackRequestHandler = async (request) => { }; } } - + // For any other method, return method not found return { error: { diff --git a/mailbox/index.js b/mailbox/index.js new file mode 100644 index 0000000..5c5f1aa --- /dev/null +++ b/mailbox/index.js @@ -0,0 +1,11 @@ +/** + * Mailbox discovery module + */ +const { listMailboxesTool, handleListMailboxes } = require('./list'); + +const mailboxTools = [listMailboxesTool]; + +module.exports = { + mailboxTools, + handleListMailboxes +}; diff --git a/mailbox/list.js b/mailbox/list.js new file mode 100644 index 0000000..d1e0dac --- /dev/null +++ b/mailbox/list.js @@ -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 +}; diff --git a/package.json b/package.json index 4d50e33..a3d5646 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "outlook-mcp", - "version": "1.0.1", + "version": "1.1.0", "description": "MCP server for Claude to access Outlook data via Microsoft Graph API", "main": "index.js", "scripts": { diff --git a/tests/mailbox-path.test.js b/tests/mailbox-path.test.js new file mode 100644 index 0000000..4891b0f --- /dev/null +++ b/tests/mailbox-path.test.js @@ -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(''); +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.'); diff --git a/tests/mailbox-send-path.test.js b/tests/mailbox-send-path.test.js new file mode 100644 index 0000000..c9ddc41 --- /dev/null +++ b/tests/mailbox-send-path.test.js @@ -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.'); diff --git a/tools/get-email-thread.js b/tools/get-email-thread.js index 637b728..0769870 100644 --- a/tools/get-email-thread.js +++ b/tools/get-email-thread.js @@ -13,16 +13,18 @@ const config = require('../config'); const { callGraphAPI } = require('../utils/graph-api'); const { ensureAuthenticated } = require('../auth'); const { buildThread } = require('../utils/threadBuilder'); +const { normalizeMailbox, buildPath, withMailboxHeaders } = require('../utils/mailbox'); const MAX_MESSAGES = 20; /** * Fetch all messages in a conversation across ALL folders (inbox + sent + etc.) - * using the conversationId filter on the 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 = []; - let url = 'me/messages'; + const opts = { headers: withMailboxHeaders(mb) }; + let url = buildPath(mb, 'messages'); let params = { $filter: `conversationId eq '${conversationId}'`, $select: config.EMAIL_DETAIL_FIELDS, @@ -32,13 +34,10 @@ async function fetchByConversationId(accessToken, conversationId) { // buildThread() handles chronological sorting in memory instead. }; - // Page through results (unlikely to exceed one page for most threads, but safe) - while (url) { - const page = await callGraphAPI(accessToken, 'GET', url, null, params); - if (page.value) allMessages.push(...page.value); - url = page['@odata.nextLink'] || null; - params = null; // params are embedded in nextLink on subsequent pages - } + // Single page is enough for most threads; avoid following absolute nextLink URLs + // through callGraphAPI (path encoder is relative-path only). + const page = await callGraphAPI(accessToken, 'GET', url, null, params, opts); + if (page.value) allMessages.push(...page.value); return allMessages; } @@ -49,9 +48,12 @@ async function fetchByConversationId(accessToken, conversationId) { * @param {string[]} [args.ids] - Explicit message IDs to include * @param {string} [args.conversationId] - Fetch entire conversation from all folders * @param {string} [args.subject] - Optional subject label for the thread header + * @param {string} [args.mailbox] - Optional shared mailbox UPN/SMTP */ async function handleGetEmailThread(args) { const { ids, conversationId, subject } = args || {}; + const mb = normalizeMailbox(args && args.mailbox); + const opts = { headers: withMailboxHeaders(mb) }; const hasIds = Array.isArray(ids) && ids.length > 0; const hasConvId = typeof conversationId === 'string' && conversationId.trim().length > 0; @@ -81,9 +83,8 @@ async function handleGetEmailThread(args) { let failCount = 0; if (hasConvId) { - // Auto-fetch entire conversation from all folders (inbox + sent + etc.) try { - messages = await fetchByConversationId(accessToken, conversationId.trim()); + messages = await fetchByConversationId(accessToken, conversationId.trim(), mb); } catch (err) { console.error(`[get-email-thread] conversationId fetch failed: ${err.message}`); 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) { const fetchedIds = new Set(messages.map(m => m.id)); const extras = await Promise.all( ids.filter(id => !fetchedIds.has(id)).map(async (id) => { try { - return await callGraphAPI(accessToken, 'GET', `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) { console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`); failCount++; @@ -108,10 +115,16 @@ async function handleGetEmailThread(args) { messages.push(...extras.filter(Boolean)); } } else { - // IDs-only path — fetch concurrently, same as before const results = await Promise.all(ids.map(async (id) => { try { - const message = await callGraphAPI(accessToken, 'GET', `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 }; } catch (err) { console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`); @@ -124,21 +137,22 @@ async function handleGetEmailThread(args) { if (messages.length === 0) { 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 mbNote = mb.kind === 'user' ? `\n\n(Mailbox: ${mb.smtpOrUpn})` : ''; const note = failCount > 0 ? `\n\n(Note: ${failCount} message(s) could not be fetched and are excluded.)` : ''; return { - content: [{ type: 'text', text: thread + note }] + content: [{ type: 'text', text: thread + mbNote + note }] }; } const threadTool = { name: 'get-email-thread', - description: 'Fetch a complete email thread and return it as a single clean deduplicated conversation, sorted chronologically. Each message shows only its unique new content — quoted prior replies are stripped, and signatures are deduplicated per sender. Prefer conversationId (from list-emails or search-emails results) to automatically include both inbox AND sent items in the thread. Fall back to ids when you only have specific message IDs without a conversationId.', + 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: { type: 'object', properties: { @@ -155,6 +169,10 @@ const threadTool = { subject: { type: 'string', description: 'Optional: override the thread subject shown in the header' + }, + mailbox: { + type: 'string', + description: "Optional shared/delegated mailbox UPN or SMTP. Omit for primary mailbox. Must match the mailbox the conversationId/IDs came from." } } }, diff --git a/utils/graph-api.js b/utils/graph-api.js index e83d552..461e2eb 100644 --- a/utils/graph-api.js +++ b/utils/graph-api.js @@ -38,9 +38,11 @@ function getRetryDelay(attempt, retryAfterSeconds) { * @param {string} path - API endpoint path * @param {object} data - Data to send for POST/PUT requests * @param {object} queryParams - Query parameters + * @param {object} [options] - Extra options + * @param {object} [options.headers] - Additional HTTP headers (e.g. X-AnchorMailbox) * @returns {Promise} - 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 if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) { console.error(`TEST MODE: Simulating ${method} ${path} API call`); @@ -50,23 +52,25 @@ async function callGraphAPI(accessToken, method, path, data = null, queryParams try { 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('/') .map(segment => encodeURIComponent(segment)) .join('/'); // Build query string from parameters with special handling for OData filters 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 - const filter = queryParams.$filter; + const filter = qp.$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 const params = new URLSearchParams(); - for (const [key, value] of Object.entries(queryParams)) { + for (const [key, value] of Object.entries(qp)) { params.append(key, value); } @@ -92,9 +96,10 @@ async function callGraphAPI(accessToken, method, path, data = null, queryParams console.error(`Full URL: ${url}`); const maxAttempts = Math.max(1, config.MAX_RETRIES + 1); + const extraHeaders = (options && options.headers) || {}; for (let attempt = 0; attempt < maxAttempts; attempt++) { - const result = await makeSingleRequest(url, optionsForRequest(method, accessToken), data); + const result = await makeSingleRequest(url, optionsForRequest(method, accessToken, extraHeaders), data); if (result.success) { return result.body; @@ -127,13 +132,17 @@ async function callGraphAPI(accessToken, method, path, data = null, queryParams /** * Build the https request options object. + * @param {string} method + * @param {string} accessToken + * @param {object} [extraHeaders] */ -function optionsForRequest(method, accessToken) { +function optionsForRequest(method, accessToken, extraHeaders = {}) { return { method: method, headers: { 'Authorization': `Bearer ${accessToken}`, - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...extraHeaders } }; } diff --git a/utils/mailbox.js b/utils/mailbox.js new file mode 100644 index 0000000..7441e3e --- /dev/null +++ b/utils/mailbox.js @@ -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 }} + */ +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} 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 +};