Initial commit: Outlook MCP Server v1.0.0
MCP server providing Microsoft Outlook integration via Graph API: - Email: list, search, read, send, thread reconstruction - Calendar: list, create, decline, cancel, delete events - Folders: list, create, move emails - Inbox rules: list, create, reorder - MSAL device code flow auth with persistent token cache - Test mode with mock data - Comprehensive README with setup, config, and tool reference 20 MCP tools across 5 modules. Node.js >= 14. MIT license.
This commit is contained in:
commit
a7886b5b2b
37 changed files with 7953 additions and 0 deletions
13
.env.example
Normal file
13
.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Microsoft Azure App Registration
|
||||||
|
# Required: your app registration client ID
|
||||||
|
MS_CLIENT_ID=your_client_id_here
|
||||||
|
|
||||||
|
# Optional: only needed for confidential client app registrations (client secret app regs).
|
||||||
|
# Leave blank or omit for public client apps (device code flow with "Allow public client flows" enabled).
|
||||||
|
# MS_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Optional: Enable test mode (uses mock data instead of real API calls)
|
||||||
|
USE_TEST_MODE=false
|
||||||
|
|
||||||
|
# Optional: Enable verbose debug logging
|
||||||
|
DEBUG_MODE=false
|
||||||
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Tokens and credentials
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.cert
|
||||||
|
*.token.json
|
||||||
|
.outlook-mcp-tokens.json
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS specific files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
304
README.md
Normal file
304
README.md
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
# Outlook MCP Server
|
||||||
|
|
||||||
|
A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes Microsoft Outlook email, calendar, folders, and inbox rules through the Microsoft Graph API.
|
||||||
|
|
||||||
|
Built for use with AI agents (Claude, Hermes, etc.) that support the MCP standard — gives your assistant the ability to read, search, and send email, manage calendar events, organize folders, and automate inbox rules.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Email**: List, search, read, send, and reconstruct full conversation threads with quoted-reply stripping and signature deduplication
|
||||||
|
- **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
|
||||||
|
- **Authentication**: MSAL device code flow with persistent token cache — no browser redirect needed
|
||||||
|
- **Test Mode**: Mock data layer for development without a real Microsoft account
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js ≥ 14
|
||||||
|
- A Microsoft Azure App Registration (see [Azure Setup](#azure-app-registration) below)
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> outlook-mcp
|
||||||
|
cd outlook-mcp
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env and set MS_CLIENT_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The server communicates over stdio (standard MCP transport). Your MCP client (Claude Desktop, Hermes, etc.) launches it as a subprocess.
|
||||||
|
|
||||||
|
### Authenticate
|
||||||
|
|
||||||
|
When you first use the server, call the `authenticate` tool. It will return a URL and a short code:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Open: https://microsoft.com/devicelogin
|
||||||
|
2. Enter code: ABC123XYZ
|
||||||
|
|
||||||
|
You have 15 minutes to complete sign-in.
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit the URL on any device, enter the code, and sign in with your Microsoft account. Tokens are cached to disk (`~/.outlook-mcp-tokens.json`) and silently refreshed by MSAL — you won't need to re-authenticate until the refresh token expires.
|
||||||
|
|
||||||
|
To check auth status without side effects, use `check-auth-status` (not `authenticate`).
|
||||||
|
|
||||||
|
## Azure App Registration
|
||||||
|
|
||||||
|
This server uses MSAL **device code flow** with a **public client** app registration.
|
||||||
|
|
||||||
|
1. Go to [Azure Portal → App Registrations](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) → **New registration**
|
||||||
|
2. Name it (e.g. "Outlook MCP")
|
||||||
|
3. Supported account types: **Accounts in any organizational directory and personal Microsoft accounts** (multi-tenant)
|
||||||
|
4. Redirect URI: leave blank (device code flow doesn't need one)
|
||||||
|
5. After creation, go to **Authentication** → scroll to **Advanced settings** → enable **Allow public client flows**
|
||||||
|
6. Go to **API permissions** → add **Delegated** Microsoft Graph permissions:
|
||||||
|
- `Mail.Read`
|
||||||
|
- `Mail.ReadWrite`
|
||||||
|
- `Mail.Send`
|
||||||
|
- `User.Read`
|
||||||
|
- `Calendars.Read`
|
||||||
|
- `Calendars.ReadWrite`
|
||||||
|
- `MailboxSettings.ReadWrite`
|
||||||
|
- `offline_access`
|
||||||
|
7. Copy the **Application (client) ID** — that's your `MS_CLIENT_ID`
|
||||||
|
|
||||||
|
No client secret is needed for public client apps.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration is via environment variables:
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `MS_CLIENT_ID` | Yes | — | Azure app registration client ID |
|
||||||
|
| `MS_CLIENT_SECRET` | No | — | Only for confidential client apps; leave blank for public client |
|
||||||
|
| `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) |
|
||||||
|
|
||||||
|
## MCP Client Configuration
|
||||||
|
|
||||||
|
### Claude Desktop
|
||||||
|
|
||||||
|
Add to `claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"outlook": {
|
||||||
|
"command": "node",
|
||||||
|
"args": ["/path/to/outlook-mcp/index.js"],
|
||||||
|
"env": {
|
||||||
|
"MS_CLIENT_ID": "your-client-id-here"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hermes Agent
|
||||||
|
|
||||||
|
Add to `config.yaml` under `mcp_servers`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
outlook-mcp:
|
||||||
|
command: node
|
||||||
|
args:
|
||||||
|
- /path/to/outlook-mcp/index.js
|
||||||
|
env:
|
||||||
|
MS_CLIENT_ID: your-client-id-here
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-Account (Multiple Instances)
|
||||||
|
|
||||||
|
The server is single-account by design — `getCachedAccount()` always returns the first cached account. To use multiple accounts (e.g. work + personal), run separate instances with isolated token caches:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mcp_servers:
|
||||||
|
outlook-mcp-work:
|
||||||
|
command: node
|
||||||
|
args: [/path/to/outlook-mcp/index.js]
|
||||||
|
env:
|
||||||
|
MS_CLIENT_ID: your-client-id
|
||||||
|
outlook-mcp-personal:
|
||||||
|
command: node
|
||||||
|
args: [/path/to/outlook-mcp/index.js]
|
||||||
|
env:
|
||||||
|
MS_CLIENT_ID: your-client-id
|
||||||
|
OUTLOOK_TOKEN_STORE_PATH: /path/to/.outlook-mcp-tokens-personal.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Each instance gets its own MSAL cache — no account-selection conflicts.
|
||||||
|
|
||||||
|
## Tools Reference
|
||||||
|
|
||||||
|
The server exposes **20 tools** across five categories.
|
||||||
|
|
||||||
|
### Authentication (3 tools)
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|---|---|
|
||||||
|
| `about` | Returns server name and version |
|
||||||
|
| `authenticate` | Starts MSAL device code flow — returns a URL and code for the user to complete sign-in |
|
||||||
|
| `check-auth-status` | Checks if a valid cached token exists (no side effects) |
|
||||||
|
|
||||||
|
### Email (6 tools)
|
||||||
|
|
||||||
|
| Tool | Key Parameters | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `list-emails` | `folder`, `count`, `dateFrom`, `dateTo`, `dateRange` | Lists emails from a folder with date filtering. Results include `conversationId`. |
|
||||||
|
| `search-emails` | `query`, `from`, `to`, `subject`, `hasAttachments`, `unreadOnly`, `count` | Progressive search with KQL fallback strategies. Results include `conversationId`. |
|
||||||
|
| `read-email` | `id` | Reads a single email with full body (auto-cleaned HTML → text) |
|
||||||
|
| `read-emails` | `ids` (max 10) | Reads multiple emails concurrently |
|
||||||
|
| `send-email` | `to`, `cc`, `bcc`, `subject`, `body`, `importance`, `saveToSentItems` | Sends an email (plain text or HTML) |
|
||||||
|
| `get-email-thread` | `conversationId` (preferred) or `ids` (max 20) | Fetches a complete conversation across all folders (inbox + sent), strips quoted replies, deduplicates signatures per sender |
|
||||||
|
|
||||||
|
**Date filtering** supports both ISO dates (`2024-06-15`) and relative ranges (`today`, `yesterday`, `last7days`, `last30days`, `thisweek`, `lastweek`, `thismonth`, `lastmonth`).
|
||||||
|
|
||||||
|
**Search strategy**: The server uses a progressive fallback approach because Graph API doesn't allow `$search` with `$orderby` or `$filter`. It tries: (1) combined KQL search with client-side boolean filtering, (2) individual term searches, (3) boolean-filter-only with `$filter` + `$orderby`, (4) fallback to recent emails.
|
||||||
|
|
||||||
|
### Calendar (5 tools)
|
||||||
|
|
||||||
|
| Tool | Key Parameters | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `list-events` | `count`, `startDate`, `endDate` | Lists events in a date range (defaults to upcoming from now) |
|
||||||
|
| `create-event` | `subject`, `start`, `end`, `attendees`, `body` | Creates a calendar event (times in ISO 8601, UTC timezone) |
|
||||||
|
| `decline-event` | `eventId`, `comment` | Declines a meeting invitation |
|
||||||
|
| `cancel-event` | `eventId`, `comment` | Cancels an event you organized |
|
||||||
|
| `delete-event` | `eventId` | Deletes an event from your calendar |
|
||||||
|
|
||||||
|
### Folders (3 tools)
|
||||||
|
|
||||||
|
| Tool | Key Parameters | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `list-folders` | `includeItemCounts`, `includeChildren` | Lists mail folders (flat list or hierarchical tree) |
|
||||||
|
| `create-folder` | `name`, `parentFolder` | Creates a new mail folder (optionally nested under a parent) |
|
||||||
|
| `move-emails` | `emailIds`, `targetFolder`, `sourceFolder` | Moves emails to a target folder by name |
|
||||||
|
|
||||||
|
### Inbox Rules (3 tools)
|
||||||
|
|
||||||
|
| Tool | Key Parameters | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `list-rules` | `includeDetails` | Lists inbox rules sorted by execution order (sequence number) |
|
||||||
|
| `create-rule` | `name`, `fromAddresses`, `containsSubject`, `hasAttachments`, `moveToFolder`, `markAsRead`, `isEnabled`, `sequence` | Creates a new inbox rule |
|
||||||
|
| `edit-rule-sequence` | `ruleName`, `sequence` | Changes the execution order of an existing rule |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
index.js # Main entry point — MCP server, tool registration, request routing
|
||||||
|
config.js # Server config, Graph API endpoints, field selections, pagination limits
|
||||||
|
.env.example # Template for environment variables
|
||||||
|
|
||||||
|
auth/
|
||||||
|
index.js # ensureAuthenticated() — the gatekeeper all tools call
|
||||||
|
token-manager.js # MSAL PublicClientApplication, device code flow, persistent cache plugin
|
||||||
|
tools.js # authenticate, check-auth-status, about tool handlers
|
||||||
|
|
||||||
|
email/
|
||||||
|
index.js # Tool definitions (schemas) for email tools
|
||||||
|
list.js # list-emails handler
|
||||||
|
search.js # search-emails handler with progressive KQL fallback
|
||||||
|
read.js # read-email handler
|
||||||
|
read-multiple.js # read-emails handler (concurrent fetch)
|
||||||
|
send.js # send-email handler
|
||||||
|
folder-utils.js # resolveFolderPath(), getFolderIdByName(), getAllFolders() — shared utilities
|
||||||
|
|
||||||
|
calendar/
|
||||||
|
index.js # Tool definitions for calendar tools
|
||||||
|
list.js # list-events handler
|
||||||
|
create.js # create-event handler
|
||||||
|
decline.js # decline-event handler
|
||||||
|
cancel.js # cancel-event handler
|
||||||
|
delete.js # delete-event handler
|
||||||
|
accept.js # ⚠️ Dead file — not registered (see Known Issues)
|
||||||
|
|
||||||
|
folder/
|
||||||
|
index.js # Tool definitions for folder tools
|
||||||
|
list.js # list-folders handler (flat + hierarchical formatting)
|
||||||
|
create.js # create-folder handler
|
||||||
|
move.js # move-emails handler
|
||||||
|
|
||||||
|
rules/
|
||||||
|
index.js # Tool definitions for rules tools + edit-rule-sequence handler
|
||||||
|
list.js # list-rules handler + getInboxRules() utility
|
||||||
|
create.js # create-rule handler
|
||||||
|
|
||||||
|
tools/
|
||||||
|
get-email-thread.js # get-email-thread tool — conversationId-based thread fetcher
|
||||||
|
|
||||||
|
utils/
|
||||||
|
graph-api.js # callGraphAPI() — HTTPS client for Microsoft Graph
|
||||||
|
bodyParser.js # HTML → text conversion, boilerplate/banner/legal block stripping, signature handling
|
||||||
|
threadBuilder.js # Quote-boundary detection, unique-content extraction, chronological thread formatting
|
||||||
|
odata-helpers.js # OData filter building, date parsing, relative date ranges
|
||||||
|
mock-data.js # Test mode mock responses
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
**Token persistence**: MSAL's cache plugin serializes/deserializes the token cache to disk on every access, so tokens survive process restarts. The cache file is plain JSON but contains MSAL's internal format — don't edit it manually.
|
||||||
|
|
||||||
|
**Body cleaning**: All email bodies pass through `bodyParser.cleanBody()` which: (1) converts HTML to text, (2) decodes HTML entities, (3) strips external-email caution banners, (4) strips legal boilerplate (DISCLAIMER / CONFIDENTIALITY NOTICE blocks), (5) optionally strips signature blocks (Outlook `id="Signature"` divs or text delimiters).
|
||||||
|
|
||||||
|
**Thread reconstruction**: `get-email-thread` fetches all messages matching a `conversationId` across all folders (inbox + sent items), sorts chronologically in memory (because `$filter` + `$orderby` on `conversationId` causes a Graph API 400), strips quoted replies using pattern matching (Outlook-style "From:/Sent:" headers, Gmail-style "On [date] [name] wrote:", `>`-prefixed lines, forwarded message blocks), and deduplicates signatures per sender (first occurrence keeps signature, repeats strip it).
|
||||||
|
|
||||||
|
**Progressive search**: Graph API doesn't allow `$search` with `$orderby` or `$filter`. The search handler tries multiple strategies in order and applies boolean filters (unread, attachments) client-side when needed.
|
||||||
|
|
||||||
|
**No external HTTP framework**: `graph-api.js` uses Node's native `https` module directly — no axios, no node-fetch, no dependencies beyond MSAL and the MCP SDK.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Test Mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test-mode
|
||||||
|
```
|
||||||
|
|
||||||
|
Sets `USE_TEST_MODE=true` — the server returns mock data for all API calls instead of hitting Graph API. Useful for testing tool schemas and response formatting without a real Microsoft account.
|
||||||
|
|
||||||
|
### MCP Inspector
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run inspect
|
||||||
|
```
|
||||||
|
|
||||||
|
Launches the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) — a web UI for interactively testing MCP tools.
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- **Token cache**: The `.outlook-mcp-tokens.json` file contains access and refresh tokens. It's in `.gitignore` by default. Protect it like a password.
|
||||||
|
- **No client secret in code**: For public client apps, no secret is needed. If you use a confidential client app, set `MS_CLIENT_SECRET` in the environment (not in the code).
|
||||||
|
- **Scopes are hardcoded**: The OAuth scopes are defined in `config.js` and include read/write for mail and calendar. Adjust if you need fewer permissions.
|
||||||
|
- **`.gitignore` covers**: `node_modules/`, `.env*`, `*.token.json`, `*.pem`, `*.key`, `.outlook-mcp-tokens.json`
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
1. **`calendar/accept.js` is not registered** — The `accept-event` handler exists in `calendar/accept.js` but is not imported or registered in `calendar/index.js`. The tool is unavailable to MCP clients. To fix: import and add it to `calendarTools`.
|
||||||
|
|
||||||
|
2. **`rules/index.js` missing `callGraphAPI` import** — `handleEditRuleSequence` calls `callGraphAPI()` but the function is never imported at the top of the file. This will throw a `ReferenceError` at runtime when `edit-rule-sequence` is invoked. To fix: add `const { callGraphAPI } = require('../utils/graph-api');` to `rules/index.js`.
|
||||||
|
|
||||||
|
3. **Calendar events are created in UTC** — `create-event` hardcodes `timeZone: "UTC"` for both start and end. Events created through the server will be in UTC regardless of the user's local timezone.
|
||||||
|
|
||||||
|
4. **`authenticate` `force` parameter is a no-op** — The `force` parameter is declared in the tool schema but never read by the handler. Calling `authenticate(force=false)` will still start a fresh device code flow. Use `check-auth-status` to check auth state without side effects.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
31
auth/index.js
Normal file
31
auth/index.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* Authentication module for Outlook MCP server
|
||||||
|
*/
|
||||||
|
const tokenManager = require('./token-manager');
|
||||||
|
const { authTools } = require('./tools');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the user is authenticated and returns an access token
|
||||||
|
* @param {boolean} forceNew - Whether to force a new authentication
|
||||||
|
* @returns {Promise<string>} - Access token
|
||||||
|
* @throws {Error} - If authentication fails
|
||||||
|
*/
|
||||||
|
async function ensureAuthenticated(forceNew = false) {
|
||||||
|
if (forceNew) {
|
||||||
|
throw new Error('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// MSAL's acquireTokenSilent handles both cache lookup and silent refresh automatically.
|
||||||
|
const accessToken = await tokenManager.getAccessToken();
|
||||||
|
if (accessToken) {
|
||||||
|
return accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
tokenManager,
|
||||||
|
authTools,
|
||||||
|
ensureAuthenticated
|
||||||
|
};
|
||||||
166
auth/token-manager.js
Normal file
166
auth/token-manager.js
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
/**
|
||||||
|
* Token management using @azure/msal-node
|
||||||
|
*/
|
||||||
|
const msal = require('@azure/msal-node');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const config = require('../config');
|
||||||
|
|
||||||
|
// Persist MSAL's internal token cache to disk so tokens survive process restarts.
|
||||||
|
const cachePlugin = {
|
||||||
|
beforeCacheAccess: async (cacheContext) => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(config.AUTH_CONFIG.tokenStorePath)) {
|
||||||
|
cacheContext.tokenCache.deserialize(
|
||||||
|
fs.readFileSync(config.AUTH_CONFIG.tokenStorePath, 'utf8')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[TokenManager] Cache read error:', e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
afterCacheAccess: async (cacheContext) => {
|
||||||
|
if (cacheContext.cacheHasChanged) {
|
||||||
|
try {
|
||||||
|
const dir = path.dirname(config.AUTH_CONFIG.tokenStorePath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
config.AUTH_CONFIG.tokenStorePath,
|
||||||
|
cacheContext.tokenCache.serialize()
|
||||||
|
);
|
||||||
|
console.error('[TokenManager] Cache saved to:', config.AUTH_CONFIG.tokenStorePath);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[TokenManager] Cache write error:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Singleton PublicClientApplication — device code flow requires a public client.
|
||||||
|
// "Allow public client flows" must be enabled in the app registration.
|
||||||
|
let _pca = null;
|
||||||
|
function getPca() {
|
||||||
|
if (!_pca) {
|
||||||
|
_pca = new msal.PublicClientApplication({
|
||||||
|
auth: {
|
||||||
|
clientId: config.AUTH_CONFIG.clientId,
|
||||||
|
authority: 'https://login.microsoftonline.com/organizations'
|
||||||
|
},
|
||||||
|
cache: { cachePlugin },
|
||||||
|
system: {
|
||||||
|
loggerOptions: {
|
||||||
|
logLevel: msal.LogLevel.Warning,
|
||||||
|
piiLoggingEnabled: false,
|
||||||
|
loggerCallback: (level, message, containsPii) => {
|
||||||
|
if (!containsPii && config.DEBUG_MODE) {
|
||||||
|
console.error(`[MSAL] ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _pca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first cached account from MSAL's token cache, or null.
|
||||||
|
*/
|
||||||
|
async function getCachedAccount() {
|
||||||
|
try {
|
||||||
|
const accounts = await getPca().getTokenCache().getAllAccounts();
|
||||||
|
return accounts && accounts.length > 0 ? accounts[0] : null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[TokenManager] getAllAccounts error:', e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a valid access token via MSAL silent flow (handles refresh automatically).
|
||||||
|
* Returns null if no cached account or silent acquire fails.
|
||||||
|
*/
|
||||||
|
async function getAccessToken() {
|
||||||
|
// Test mode shortcut
|
||||||
|
if (config.USE_TEST_MODE) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(config.AUTH_CONFIG.tokenStorePath, 'utf8');
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (data._testMode) return data.access_token;
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = await getCachedAccount();
|
||||||
|
if (!account) return null;
|
||||||
|
const result = await getPca().acquireTokenSilent({
|
||||||
|
scopes: config.AUTH_CONFIG.scopes,
|
||||||
|
account
|
||||||
|
});
|
||||||
|
return result ? result.accessToken : null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[TokenManager] Silent token acquire failed:', e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates the device code flow.
|
||||||
|
* Returns { deviceCodeInfo, tokenPromise } immediately after the code is issued.
|
||||||
|
* tokenPromise resolves when the user completes sign-in; MSAL handles all polling.
|
||||||
|
*/
|
||||||
|
async function initiateDeviceCodeFlow() {
|
||||||
|
const pca = getPca();
|
||||||
|
|
||||||
|
let resolveDeviceCode, rejectDeviceCode;
|
||||||
|
const deviceCodeInfoPromise = new Promise((resolve, reject) => {
|
||||||
|
resolveDeviceCode = resolve;
|
||||||
|
rejectDeviceCode = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenPromise = pca.acquireTokenByDeviceCode({
|
||||||
|
scopes: config.AUTH_CONFIG.scopes,
|
||||||
|
deviceCodeCallback: (response) => {
|
||||||
|
console.error('[TokenManager] Device code issued:', response.userCode);
|
||||||
|
resolveDeviceCode(response);
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
// If the flow fails before the callback fires, reject the code promise too
|
||||||
|
rejectDeviceCode(err);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceCodeInfo = await deviceCodeInfoPromise;
|
||||||
|
return { deviceCodeInfo, tokenPromise };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silent refresh — with MSAL this is handled transparently inside getAccessToken().
|
||||||
|
*/
|
||||||
|
async function refreshAccessToken() {
|
||||||
|
return getAccessToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates test tokens for USE_TEST_MODE.
|
||||||
|
*/
|
||||||
|
function createTestTokens() {
|
||||||
|
const testData = {
|
||||||
|
_testMode: true,
|
||||||
|
access_token: 'test_access_token_' + Date.now(),
|
||||||
|
expires_at: Date.now() + 3600 * 1000
|
||||||
|
};
|
||||||
|
const dir = path.dirname(config.AUTH_CONFIG.tokenStorePath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(config.AUTH_CONFIG.tokenStorePath, JSON.stringify(testData, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getPca,
|
||||||
|
getCachedAccount,
|
||||||
|
getAccessToken,
|
||||||
|
initiateDeviceCodeFlow,
|
||||||
|
refreshAccessToken,
|
||||||
|
createTestTokens
|
||||||
|
};
|
||||||
113
auth/tools.js
Normal file
113
auth/tools.js
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
/**
|
||||||
|
* Authentication-related tools for the Outlook MCP server
|
||||||
|
*/
|
||||||
|
const config = require('../config');
|
||||||
|
const tokenManager = require('./token-manager');
|
||||||
|
|
||||||
|
async function handleAbout() {
|
||||||
|
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.`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates device code flow via MSAL. Returns the code and URL to the user
|
||||||
|
* immediately; MSAL polls internally until the user completes sign-in.
|
||||||
|
*/
|
||||||
|
async function handleAuthenticate(args) {
|
||||||
|
if (config.USE_TEST_MODE) {
|
||||||
|
tokenManager.createTestTokens();
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: 'Successfully authenticated with Microsoft Graph API (test mode)' }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let flowResult;
|
||||||
|
try {
|
||||||
|
flowResult = await tokenManager.initiateDeviceCodeFlow();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[authenticate] Failed to initiate device code flow:', err.message);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Failed to start authentication: ${err.message}` }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { deviceCodeInfo, tokenPromise } = flowResult;
|
||||||
|
const { userCode, verificationUri, expiresIn } = deviceCodeInfo;
|
||||||
|
const minutesRemaining = Math.floor(expiresIn / 60);
|
||||||
|
|
||||||
|
// MSAL polls internally — just log completion when it resolves
|
||||||
|
tokenPromise
|
||||||
|
.then(result => {
|
||||||
|
if (result) console.error('[authenticate] Sign-in completed — MSAL cached tokens.');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(`[authenticate] Device code flow ended: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: [
|
||||||
|
`To sign in to Microsoft, please:`,
|
||||||
|
``,
|
||||||
|
` 1. Open: ${verificationUri}`,
|
||||||
|
` 2. Enter code: ${userCode}`,
|
||||||
|
``,
|
||||||
|
`You have ${minutesRemaining} minutes to complete sign-in.`,
|
||||||
|
`After signing in, call check-auth-status to confirm.`
|
||||||
|
].join('\n')
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCheckAuthStatus() {
|
||||||
|
console.error(`[CHECK-AUTH-STATUS] Checking for valid token...`);
|
||||||
|
try {
|
||||||
|
const token = await tokenManager.getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
console.error('[CHECK-AUTH-STATUS] No valid token found');
|
||||||
|
return { content: [{ type: "text", text: "Not authenticated" }] };
|
||||||
|
}
|
||||||
|
console.error('[CHECK-AUTH-STATUS] Valid token acquired');
|
||||||
|
return { content: [{ type: "text", text: "Authenticated and ready" }] };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[CHECK-AUTH-STATUS] Error:', e.message);
|
||||||
|
return { content: [{ type: "text", text: "Not authenticated" }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authTools = [
|
||||||
|
{
|
||||||
|
name: "about",
|
||||||
|
description: "Returns information about this Outlook Assistant server",
|
||||||
|
inputSchema: { type: "object", properties: {}, required: [] },
|
||||||
|
handler: handleAbout
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
force: {
|
||||||
|
anyOf: [{ type: "boolean" }, { type: "string" }],
|
||||||
|
description: "Force re-authentication even if already authenticated"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: []
|
||||||
|
},
|
||||||
|
handler: handleAuthenticate
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "check-auth-status",
|
||||||
|
description: "Check the current authentication status with Microsoft Graph API",
|
||||||
|
inputSchema: { type: "object", properties: {}, required: [] },
|
||||||
|
handler: handleCheckAuthStatus
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = { authTools, handleAbout, handleAuthenticate, handleCheckAuthStatus };
|
||||||
64
calendar/accept.js
Normal file
64
calendar/accept.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Accept event functionality
|
||||||
|
*/
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept event handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleAcceptEvent(args) {
|
||||||
|
const { eventId, comment } = args;
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "Event ID is required to accept an event."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get access token
|
||||||
|
const accessToken = await ensureAuthenticated();
|
||||||
|
|
||||||
|
// Build API endpoint
|
||||||
|
const endpoint = `me/events/${eventId}/accept`;
|
||||||
|
|
||||||
|
// Request body
|
||||||
|
const body = {
|
||||||
|
comment: comment || "Accepted via API"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make API call
|
||||||
|
await callGraphAPI(accessToken, 'POST', endpoint, body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Event with ID ${eventId} has been successfully accepted.`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 accepting event: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleAcceptEvent;
|
||||||
64
calendar/cancel.js
Normal file
64
calendar/cancel.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Cancel event functionality
|
||||||
|
*/
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel event handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleCancelEvent(args) {
|
||||||
|
const { eventId, comment } = args;
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "Event ID is required to cancel an event."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get access token
|
||||||
|
const accessToken = await ensureAuthenticated();
|
||||||
|
|
||||||
|
// Build API endpoint
|
||||||
|
const endpoint = `me/events/${eventId}/cancel`;
|
||||||
|
|
||||||
|
// Request body
|
||||||
|
const body = {
|
||||||
|
comment: comment || "Cancelled via API"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make API call
|
||||||
|
await callGraphAPI(accessToken, 'POST', endpoint, body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Event with ID ${eventId} has been successfully cancelled.`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 cancelling event: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleCancelEvent;
|
||||||
68
calendar/create.js
Normal file
68
calendar/create.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* Create event functionality
|
||||||
|
*/
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create event handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleCreateEvent(args) {
|
||||||
|
const { subject, start, end, attendees, body } = args;
|
||||||
|
|
||||||
|
if (!subject || !start || !end) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "Subject, start, and end times are required to create an event."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get access token
|
||||||
|
const accessToken = await ensureAuthenticated();
|
||||||
|
|
||||||
|
// Build API endpoint
|
||||||
|
const endpoint = `me/events`;
|
||||||
|
|
||||||
|
// Request body
|
||||||
|
const bodyContent = {
|
||||||
|
subject,
|
||||||
|
start: { dateTime: start, timeZone: "UTC" },
|
||||||
|
end: { dateTime: end, timeZone: "UTC" },
|
||||||
|
attendees: attendees?.map(email => ({ emailAddress: { address: email }, type: "required" })),
|
||||||
|
body: { contentType: "HTML", content: body || "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make API call
|
||||||
|
const response = await callGraphAPI(accessToken, 'POST', endpoint, bodyContent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Event '${subject}' has been successfully created.`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 creating event: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleCreateEvent;
|
||||||
64
calendar/decline.js
Normal file
64
calendar/decline.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Decline event functionality
|
||||||
|
*/
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decline event handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleDeclineEvent(args) {
|
||||||
|
const { eventId, comment } = args;
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "Event ID is required to decline an event."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get access token
|
||||||
|
const accessToken = await ensureAuthenticated();
|
||||||
|
|
||||||
|
// Build API endpoint
|
||||||
|
const endpoint = `me/events/${eventId}/decline`;
|
||||||
|
|
||||||
|
// Request body
|
||||||
|
const body = {
|
||||||
|
comment: comment || "Declined via API"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make API call
|
||||||
|
await callGraphAPI(accessToken, 'POST', endpoint, body);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Event with ID ${eventId} has been successfully declined.`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 declining event: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleDeclineEvent;
|
||||||
59
calendar/delete.js
Normal file
59
calendar/delete.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/**
|
||||||
|
* Delete event functionality
|
||||||
|
*/
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete event handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleDeleteEvent(args) {
|
||||||
|
const { eventId } = args;
|
||||||
|
|
||||||
|
if (!eventId) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "Event ID is required to delete an event."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get access token
|
||||||
|
const accessToken = await ensureAuthenticated();
|
||||||
|
|
||||||
|
// Build API endpoint
|
||||||
|
const endpoint = `me/events/${eventId}`;
|
||||||
|
|
||||||
|
// Make API call
|
||||||
|
await callGraphAPI(accessToken, 'DELETE', endpoint);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Event with ID ${eventId} has been successfully deleted.`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 deleting event: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleDeleteEvent;
|
||||||
131
calendar/index.js
Normal file
131
calendar/index.js
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/**
|
||||||
|
* Calendar module for Outlook MCP server
|
||||||
|
*/
|
||||||
|
const handleListEvents = require('./list');
|
||||||
|
const handleDeclineEvent = require('./decline');
|
||||||
|
const handleCreateEvent = require('./create');
|
||||||
|
const handleCancelEvent = require('./cancel');
|
||||||
|
const handleDeleteEvent = require('./delete');
|
||||||
|
|
||||||
|
// Calendar tool definitions
|
||||||
|
const calendarTools = [
|
||||||
|
{
|
||||||
|
name: "list-events",
|
||||||
|
description: "List calendar events. Defaults to upcoming events from now. Use startDate/endDate to query a specific date range including past events (e.g. to reconstruct what was scheduled on a given day).",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
count: {
|
||||||
|
anyOf: [{ type: "number" }, { type: "string" }],
|
||||||
|
description: "Number of events to retrieve (default: 10, max: 500). WARNING: Large counts may consume significant context tokens."
|
||||||
|
},
|
||||||
|
startDate: {
|
||||||
|
type: "string",
|
||||||
|
description: "Start of date range (ISO 'YYYY-MM-DD' or full ISO 8601 datetime). Defaults to now if omitted."
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: "string",
|
||||||
|
description: "End of date range (ISO 'YYYY-MM-DD' or full ISO 8601 datetime). To query a single day set startDate and endDate to the same date."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: []
|
||||||
|
},
|
||||||
|
handler: handleListEvents
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "decline-event",
|
||||||
|
description: "Declines a calendar event",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
eventId: {
|
||||||
|
type: "string",
|
||||||
|
description: "The ID of the event to decline"
|
||||||
|
},
|
||||||
|
comment: {
|
||||||
|
type: "string",
|
||||||
|
description: "Optional comment for declining the event"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["eventId"]
|
||||||
|
},
|
||||||
|
handler: handleDeclineEvent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create-event",
|
||||||
|
description: "Creates a new calendar event",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
subject: {
|
||||||
|
type: "string",
|
||||||
|
description: "The subject of the event"
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
type: "string",
|
||||||
|
description: "The start time of the event in ISO 8601 format"
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
type: "string",
|
||||||
|
description: "The end time of the event in ISO 8601 format"
|
||||||
|
},
|
||||||
|
attendees: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "string"
|
||||||
|
},
|
||||||
|
description: "List of attendee email addresses"
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "string",
|
||||||
|
description: "Optional body content for the event"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["subject", "start", "end"]
|
||||||
|
},
|
||||||
|
handler: handleCreateEvent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancel-event",
|
||||||
|
description: "Cancels a calendar event",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
eventId: {
|
||||||
|
type: "string",
|
||||||
|
description: "The ID of the event to cancel"
|
||||||
|
},
|
||||||
|
comment: {
|
||||||
|
type: "string",
|
||||||
|
description: "Optional comment for cancelling the event"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["eventId"]
|
||||||
|
},
|
||||||
|
handler: handleCancelEvent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "delete-event",
|
||||||
|
description: "Deletes a calendar event",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
eventId: {
|
||||||
|
type: "string",
|
||||||
|
description: "The ID of the event to delete"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["eventId"]
|
||||||
|
},
|
||||||
|
handler: handleDeleteEvent
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
calendarTools,
|
||||||
|
handleListEvents,
|
||||||
|
handleDeclineEvent,
|
||||||
|
handleCreateEvent,
|
||||||
|
handleCancelEvent,
|
||||||
|
handleDeleteEvent
|
||||||
|
};
|
||||||
94
calendar/list.js
Normal file
94
calendar/list.js
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* List events functionality
|
||||||
|
*/
|
||||||
|
const config = require('../config');
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List events handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleListEvents(args) {
|
||||||
|
const count = Math.min(parseInt(args.count, 10) || 10, config.MAX_RESULT_COUNT);
|
||||||
|
|
||||||
|
// Resolve date range — accept plain YYYY-MM-DD or full ISO datetime
|
||||||
|
const toISO = (dateStr, endOfDay = false) => {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
// If just a date (no T), append time component
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||||
|
return endOfDay ? `${dateStr}T23:59:59Z` : `${dateStr}T00:00:00Z`;
|
||||||
|
}
|
||||||
|
return new Date(dateStr).toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startISO = toISO(args.startDate) || new Date().toISOString();
|
||||||
|
const endISO = toISO(args.endDate, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get access token
|
||||||
|
const accessToken = await ensureAuthenticated();
|
||||||
|
|
||||||
|
// Build API endpoint
|
||||||
|
let endpoint = 'me/events';
|
||||||
|
|
||||||
|
// Build date filter
|
||||||
|
const filterParts = [`start/dateTime ge '${startISO}'`];
|
||||||
|
if (endISO) filterParts.push(`end/dateTime le '${endISO}'`);
|
||||||
|
|
||||||
|
// Add query parameters
|
||||||
|
const queryParams = {
|
||||||
|
$top: count,
|
||||||
|
$orderby: 'start/dateTime',
|
||||||
|
$filter: filterParts.join(' and '),
|
||||||
|
$select: config.CALENDAR_SELECT_FIELDS
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make API call
|
||||||
|
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams);
|
||||||
|
|
||||||
|
if (!response.value || response.value.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "No calendar events found."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format results
|
||||||
|
const eventList = response.value.map((event, index) => {
|
||||||
|
const startDate = new Date(event.start.dateTime).toLocaleString(event.start.timeZone);
|
||||||
|
const endDate = new Date(event.end.dateTime).toLocaleString(event.end.timeZone);
|
||||||
|
const location = event.location.displayName || 'No location';
|
||||||
|
|
||||||
|
return `${index + 1}. ${event.subject} - Location: ${location}\nStart: ${startDate}\nEnd: ${endDate}\nSubject: ${event.subject}\nSummary: ${event.bodyPreview}\nID: ${event.id}\n`;
|
||||||
|
}).join("\n");
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Found ${response.value.length} events:\n\n${eventList}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 events: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleListEvents;
|
||||||
49
config.js
Normal file
49
config.js
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* Configuration for Outlook MCP Server
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
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';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Server information
|
||||||
|
SERVER_NAME: "outlook-assistant-main",
|
||||||
|
SERVER_VERSION: "1.0.0",
|
||||||
|
|
||||||
|
// Test mode setting
|
||||||
|
USE_TEST_MODE: process.env.USE_TEST_MODE === 'true',
|
||||||
|
|
||||||
|
// Debug mode setting
|
||||||
|
DEBUG_MODE: process.env.DEBUG_MODE === 'true',
|
||||||
|
|
||||||
|
// 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'],
|
||||||
|
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,start,end,location,bodyPreview,isAllDay,recurrence,attendees',
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
|
||||||
|
// Calendar constants
|
||||||
|
CALENDAR_SELECT_FIELDS: 'id,subject,bodyPreview,start,end,location,organizer,attendees,isAllDay,isCancelled',
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
DEFAULT_PAGE_SIZE: 25,
|
||||||
|
MAX_RESULT_COUNT: 500
|
||||||
|
};
|
||||||
171
email/folder-utils.js
Normal file
171
email/folder-utils.js
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* Email folder utilities
|
||||||
|
*/
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache of folder information to reduce API calls
|
||||||
|
* Format: { userId: { folderName: { id, path } } }
|
||||||
|
*/
|
||||||
|
const folderCache = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a folder name to its endpoint path
|
||||||
|
* @param {string} accessToken - Access token
|
||||||
|
* @param {string} folderName - Folder name to resolve
|
||||||
|
* @returns {Promise<string>} - Resolved endpoint path
|
||||||
|
*/
|
||||||
|
async function resolveFolderPath(accessToken, folderName) {
|
||||||
|
// Default to inbox if no folder specified
|
||||||
|
if (!folderName) {
|
||||||
|
return 'me/messages';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle well-known folder names
|
||||||
|
const wellKnownFolders = {
|
||||||
|
'inbox': 'me/messages',
|
||||||
|
'drafts': 'me/mailFolders/drafts/messages',
|
||||||
|
'sent': 'me/mailFolders/sentItems/messages',
|
||||||
|
'deleted': 'me/mailFolders/deletedItems/messages',
|
||||||
|
'junk': 'me/mailFolders/junkemail/messages',
|
||||||
|
'archive': 'me/mailFolders/archive/messages'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if it's a well-known folder (case-insensitive)
|
||||||
|
const lowerFolderName = folderName.toLowerCase();
|
||||||
|
if (wellKnownFolders[lowerFolderName]) {
|
||||||
|
console.error(`Using well-known folder path for "${folderName}"`);
|
||||||
|
return wellKnownFolders[lowerFolderName];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to find the folder by name
|
||||||
|
const folderId = await getFolderIdByName(accessToken, folderName);
|
||||||
|
if (folderId) {
|
||||||
|
const path = `me/mailFolders/${folderId}/messages`;
|
||||||
|
console.error(`Resolved folder "${folderName}" to path: ${path}`);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found, fall back to inbox
|
||||||
|
console.error(`Couldn't find folder "${folderName}", falling back to inbox`);
|
||||||
|
return 'me/messages';
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error resolving folder "${folderName}": ${error.message}`);
|
||||||
|
return 'me/messages';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ID of a mail folder by its name
|
||||||
|
* @param {string} accessToken - Access token
|
||||||
|
* @param {string} folderName - Name of the folder to find
|
||||||
|
* @returns {Promise<string|null>} - Folder ID or null if not found
|
||||||
|
*/
|
||||||
|
async function getFolderIdByName(accessToken, folderName) {
|
||||||
|
try {
|
||||||
|
// First try with exact match filter
|
||||||
|
console.error(`Looking for folder with name "${folderName}"`);
|
||||||
|
const response = await callGraphAPI(
|
||||||
|
accessToken,
|
||||||
|
'GET',
|
||||||
|
'me/mailFolders',
|
||||||
|
null,
|
||||||
|
{ $filter: `displayName eq '${folderName}'` }
|
||||||
|
);
|
||||||
|
|
||||||
|
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',
|
||||||
|
null,
|
||||||
|
{ $top: 100 }
|
||||||
|
);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
console.error(`Error finding folder "${folderName}": ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all mail folders
|
||||||
|
* @param {string} accessToken - Access token
|
||||||
|
* @returns {Promise<Array>} - Array of folder objects
|
||||||
|
*/
|
||||||
|
async function getAllFolders(accessToken) {
|
||||||
|
try {
|
||||||
|
// Get top-level folders
|
||||||
|
const response = await callGraphAPI(
|
||||||
|
accessToken,
|
||||||
|
'GET',
|
||||||
|
'me/mailFolders',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
$top: 100,
|
||||||
|
$select: 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
$select: 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveFolderPath,
|
||||||
|
getFolderIdByName,
|
||||||
|
getAllFolders
|
||||||
|
};
|
||||||
168
email/index.js
Normal file
168
email/index.js
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
/**
|
||||||
|
* Email module for Outlook MCP server
|
||||||
|
*/
|
||||||
|
const handleListEmails = require('./list');
|
||||||
|
const handleSearchEmails = require('./search');
|
||||||
|
const handleReadEmail = require('./read');
|
||||||
|
const handleReadMultipleEmails = require('./read-multiple');
|
||||||
|
const handleSendEmail = require('./send');
|
||||||
|
|
||||||
|
// 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.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
folder: {
|
||||||
|
type: "string",
|
||||||
|
description: "Email folder to list (e.g., 'inbox', 'sent', 'drafts', default: 'inbox')"
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
anyOf: [{ type: "number" }, { type: "string" }],
|
||||||
|
description: "Number of emails to retrieve (default: 10, max: 500). WARNING: Large counts may consume significant context tokens."
|
||||||
|
},
|
||||||
|
dateFrom: {
|
||||||
|
type: "string",
|
||||||
|
description: "Start date for email filtering (ISO format: 'YYYY-MM-DD' or relative: 'yesterday', 'last7days')"
|
||||||
|
},
|
||||||
|
dateTo: {
|
||||||
|
type: "string",
|
||||||
|
description: "End date for email filtering (ISO format: 'YYYY-MM-DD' or relative: 'today', 'tomorrow')"
|
||||||
|
},
|
||||||
|
dateRange: {
|
||||||
|
type: "string",
|
||||||
|
description: "Predefined date range ('today', 'yesterday', 'last7days', 'last30days', 'thisweek', 'lastweek', 'thismonth', 'lastmonth')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: []
|
||||||
|
},
|
||||||
|
handler: handleListEmails
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "Full-text search query (searches across subject, body, and sender)"
|
||||||
|
},
|
||||||
|
folder: {
|
||||||
|
type: "string",
|
||||||
|
description: "Email folder to search in (default: 'inbox'). Use 'list-folders' to discover folder names."
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
type: "string",
|
||||||
|
description: "Filter by sender — can be an email address or display name (e.g. 'john@example.com' or 'John')"
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: "string",
|
||||||
|
description: "Filter by recipient email address or display name"
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
type: "string",
|
||||||
|
description: "Filter by subject line keywords (e.g. 'invoice', 'meeting notes')"
|
||||||
|
},
|
||||||
|
hasAttachments: {
|
||||||
|
anyOf: [{ type: "boolean" }, { type: "string" }],
|
||||||
|
description: "Set true to return only emails that have attachments"
|
||||||
|
},
|
||||||
|
unreadOnly: {
|
||||||
|
anyOf: [{ type: "boolean" }, { type: "string" }],
|
||||||
|
description: "Set true to return only unread emails"
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
anyOf: [{ type: "number" }, { type: "string" }],
|
||||||
|
description: "Number of results to return (default: 10, max: 500). WARNING: Large counts may consume significant context tokens."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: []
|
||||||
|
},
|
||||||
|
handler: handleSearchEmails
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read-email",
|
||||||
|
description: "Reads the content of a specific email",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: "string",
|
||||||
|
description: "ID of the email to read"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["id"]
|
||||||
|
},
|
||||||
|
handler: handleReadEmail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read-emails",
|
||||||
|
description: "Reads the content of multiple emails at once",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
ids: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "string"
|
||||||
|
},
|
||||||
|
description: "Array of email IDs to read (max: 10)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["ids"]
|
||||||
|
},
|
||||||
|
handler: handleReadMultipleEmails
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "send-email",
|
||||||
|
description: "Composes and sends a new email",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
to: {
|
||||||
|
type: "string",
|
||||||
|
description: "Comma-separated list of recipient email addresses"
|
||||||
|
},
|
||||||
|
cc: {
|
||||||
|
type: "string",
|
||||||
|
description: "Comma-separated list of CC recipient email addresses"
|
||||||
|
},
|
||||||
|
bcc: {
|
||||||
|
type: "string",
|
||||||
|
description: "Comma-separated list of BCC recipient email addresses"
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
type: "string",
|
||||||
|
description: "Email subject"
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "string",
|
||||||
|
description: "Email body content (can be plain text or HTML)"
|
||||||
|
},
|
||||||
|
importance: {
|
||||||
|
type: "string",
|
||||||
|
description: "Email importance (normal, high, low)",
|
||||||
|
enum: ["normal", "high", "low"]
|
||||||
|
},
|
||||||
|
saveToSentItems: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Whether to save the email to sent items"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["to", "subject", "body"]
|
||||||
|
},
|
||||||
|
handler: handleSendEmail
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
emailTools,
|
||||||
|
handleListEmails,
|
||||||
|
handleSearchEmails,
|
||||||
|
handleReadEmail,
|
||||||
|
handleReadMultipleEmails,
|
||||||
|
handleSendEmail
|
||||||
|
};
|
||||||
100
email/list.js
Normal file
100
email/list.js
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
/**
|
||||||
|
* List emails functionality
|
||||||
|
*/
|
||||||
|
const config = require('../config');
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
const { buildODataFilter, buildDateFilter } = require('../utils/odata-helpers');
|
||||||
|
const { resolveFolderPath } = require('./folder-utils');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List emails handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleListEmails(args) {
|
||||||
|
const folder = args.folder || "inbox";
|
||||||
|
const count = Math.min(parseInt(args.count, 10) || 10, config.MAX_RESULT_COUNT);
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
if (!response.value || response.value.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `No emails found in ${folder}.`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format results
|
||||||
|
const emailList = response.value.map((email, index) => {
|
||||||
|
const sender = email.from ? email.from.emailAddress : { name: 'Unknown', address: 'unknown' };
|
||||||
|
const date = new Date(email.receivedDateTime).toLocaleString();
|
||||||
|
const readStatus = email.isRead ? '' : '[UNREAD] ';
|
||||||
|
const convLine = email.conversationId ? `ConversationID: ${email.conversationId}\n` : '';
|
||||||
|
|
||||||
|
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 (args.dateRange) {
|
||||||
|
resultMessage += ` (${args.dateRange})`;
|
||||||
|
} else if (args.dateFrom || args.dateTo) {
|
||||||
|
const dateInfo = [];
|
||||||
|
if (args.dateFrom) dateInfo.push(`from: ${args.dateFrom}`);
|
||||||
|
if (args.dateTo) dateInfo.push(`to: ${args.dateTo}`);
|
||||||
|
resultMessage += ` (${dateInfo.join(', ')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultMessage += `:\n\n${emailList}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: resultMessage
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 emails: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleListEmails;
|
||||||
155
email/read-multiple.js
Normal file
155
email/read-multiple.js
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* Read multiple emails functionality
|
||||||
|
*/
|
||||||
|
const config = require('../config');
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
const { cleanBody } = require('../utils/bodyParser');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a single email for display
|
||||||
|
* @param {object} email - Email object from Graph API
|
||||||
|
* @param {string} emailId - Email ID for error context
|
||||||
|
* @returns {string} - Formatted email text
|
||||||
|
*/
|
||||||
|
function formatEmail(email, emailId) {
|
||||||
|
if (!email) {
|
||||||
|
return `Email ID ${emailId}: Not found or inaccessible`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = new Date(email.receivedDateTime).toLocaleString();
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
Date: ${date}
|
||||||
|
Importance: ${email.importance || 'normal'}
|
||||||
|
Has Attachments: ${email.hasAttachments ? 'Yes' : 'No'}
|
||||||
|
|
||||||
|
${body}`;
|
||||||
|
} catch (error) {
|
||||||
|
return `Email ID ${emailId}: Error formatting email - ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read multiple emails handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleReadMultipleEmails(args) {
|
||||||
|
const emailIds = args.ids;
|
||||||
|
|
||||||
|
if (!emailIds || !Array.isArray(emailIds) || emailIds.length === 0) {
|
||||||
|
return {
|
||||||
|
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",
|
||||||
|
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 queryParams = {
|
||||||
|
$select: config.EMAIL_DETAIL_FIELDS
|
||||||
|
};
|
||||||
|
|
||||||
|
const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams);
|
||||||
|
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 formattedEmails = results.map((result, index) => {
|
||||||
|
const emailNumber = index + 1;
|
||||||
|
const separator = "=".repeat(80);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return `${separator}
|
||||||
|
EMAIL ${emailNumber} (ID: ${result.emailId})
|
||||||
|
${separator}
|
||||||
|
Error: ${result.error}`;
|
||||||
|
} else {
|
||||||
|
const formattedEmail = formatEmail(result.email, result.emailId);
|
||||||
|
return `${separator}
|
||||||
|
EMAIL ${emailNumber} (ID: ${result.emailId})
|
||||||
|
${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` : ''}.
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: summary + formattedEmails.join('\n\n')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} 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 accessing emails: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleReadMultipleEmails;
|
||||||
126
email/read.js
Normal file
126
email/read.js
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* Read email functionality
|
||||||
|
*/
|
||||||
|
const config = require('../config');
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
const { cleanBody } = require('../utils/bodyParser');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read email handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleReadEmail(args) {
|
||||||
|
const emailId = args.id;
|
||||||
|
|
||||||
|
if (!emailId) {
|
||||||
|
return {
|
||||||
|
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)}`;
|
||||||
|
const queryParams = {
|
||||||
|
$select: config.EMAIL_DETAIL_FIELDS
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const email = await callGraphAPI(accessToken, 'GET', endpoint, null, queryParams);
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Email with ID ${emailId} not found.`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = new Date(email.receivedDateTime).toLocaleString();
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
To: ${to}
|
||||||
|
${cc !== 'None' ? `CC: ${cc}\n` : ''}${bcc !== 'None' ? `BCC: ${bcc}\n` : ''}Subject: ${email.subject}
|
||||||
|
Date: ${date}
|
||||||
|
Importance: ${email.importance || 'normal'}
|
||||||
|
Has Attachments: ${email.hasAttachments ? 'Yes' : 'No'}
|
||||||
|
|
||||||
|
${body}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: formattedEmail
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} 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.`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Failed to read email: ${error.message}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 accessing email: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleReadEmail;
|
||||||
257
email/search.js
Normal file
257
email/search.js
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
/**
|
||||||
|
* Improved search emails functionality
|
||||||
|
*/
|
||||||
|
const config = require('../config');
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
const { resolveFolderPath } = require('./folder-utils');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search emails handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleSearchEmails(args) {
|
||||||
|
const folder = args.folder || "inbox";
|
||||||
|
// Coerce count — MCP hosts may send numbers as strings
|
||||||
|
const count = Math.min(parseInt(args.count, 10) || 10, config.MAX_RESULT_COUNT);
|
||||||
|
const query = args.query || '';
|
||||||
|
const from = args.from || '';
|
||||||
|
const to = args.to || '';
|
||||||
|
const subject = args.subject || '';
|
||||||
|
// Coerce booleans — MCP hosts may send "true"/"false" as strings
|
||||||
|
const hasAttachments = args.hasAttachments === true || args.hasAttachments === 'true' ? true : undefined;
|
||||||
|
const unreadOnly = args.unreadOnly === true || args.unreadOnly === 'true' ? true : undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get access token
|
||||||
|
const accessToken = await ensureAuthenticated();
|
||||||
|
|
||||||
|
// Resolve the folder path
|
||||||
|
const endpoint = await resolveFolderPath(accessToken, folder);
|
||||||
|
console.error(`Using endpoint: ${endpoint} for folder: ${folder}`);
|
||||||
|
|
||||||
|
// Execute progressive search
|
||||||
|
const response = await progressiveSearch(
|
||||||
|
endpoint,
|
||||||
|
accessToken,
|
||||||
|
{ query, from, to, subject },
|
||||||
|
{ hasAttachments, unreadOnly },
|
||||||
|
count
|
||||||
|
);
|
||||||
|
|
||||||
|
return formatSearchResults(response);
|
||||||
|
} catch (error) {
|
||||||
|
// Handle authentication errors
|
||||||
|
if (error.message === 'Authentication required') {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "Authentication required. Please use the 'authenticate' tool first."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// General error response
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Error searching emails: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a search with progressively simpler fallback strategies.
|
||||||
|
*
|
||||||
|
* Microsoft Graph API constraints on /me/messages:
|
||||||
|
* - $search and $orderby CANNOT be used together (causes 400)
|
||||||
|
* - $search and $filter CANNOT be used together (causes 400)
|
||||||
|
* - $filter and $orderby CAN be used together
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* 1. Text terms present → $search with proper KQL (no $orderby, no $filter),
|
||||||
|
* then apply boolean filters client-side
|
||||||
|
* 2. Text terms present → retry with each term individually (same approach)
|
||||||
|
* 3. Only boolean filters → $filter + $orderby (fully supported)
|
||||||
|
* 4. Fallback → recent emails (labeled in response)
|
||||||
|
*/
|
||||||
|
async function progressiveSearch(endpoint, accessToken, searchTerms, filterTerms, count) {
|
||||||
|
const hasTextTerms = !!(searchTerms.query || searchTerms.from || searchTerms.to || searchTerms.subject);
|
||||||
|
const hasBooleanFilters = filterTerms.hasAttachments === true || filterTerms.unreadOnly === true;
|
||||||
|
|
||||||
|
// 1. Try combined KQL search (text terms only — boolean filters applied client-side)
|
||||||
|
if (hasTextTerms) {
|
||||||
|
try {
|
||||||
|
const kqlQuery = buildKqlQuery(searchTerms);
|
||||||
|
const params = {
|
||||||
|
$top: count,
|
||||||
|
$select: config.EMAIL_SELECT_FIELDS,
|
||||||
|
$search: kqlQuery
|
||||||
|
// NOTE: NO $orderby — not allowed with $search
|
||||||
|
// NOTE: NO $filter — not allowed with $search
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error(`Attempting combined KQL search: ${kqlQuery}`);
|
||||||
|
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
|
||||||
|
|
||||||
|
if (response.value && response.value.length > 0) {
|
||||||
|
const filtered = applyClientSideFilters(response.value, filterTerms);
|
||||||
|
console.error(`Combined search found ${response.value.length} results, ${filtered.length} after filtering`);
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
return { value: filtered };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Combined KQL search failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try each search term individually (priority: subject → from → to → query)
|
||||||
|
const termPriority = ['subject', 'from', 'to', 'query'];
|
||||||
|
for (const term of termPriority) {
|
||||||
|
if (!searchTerms[term]) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const kqlQuery = buildSingleTermKql(term, searchTerms[term]);
|
||||||
|
const params = {
|
||||||
|
$top: count,
|
||||||
|
$select: config.EMAIL_SELECT_FIELDS,
|
||||||
|
$search: kqlQuery
|
||||||
|
// NOTE: NO $orderby, NO $filter
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error(`Attempting single-term search (${term}): ${kqlQuery}`);
|
||||||
|
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
|
||||||
|
|
||||||
|
if (response.value && response.value.length > 0) {
|
||||||
|
const filtered = applyClientSideFilters(response.value, filterTerms);
|
||||||
|
console.error(`Search on ${term} found ${response.value.length} results, ${filtered.length} after filtering`);
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
return { value: filtered };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Single-term search (${term}) failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Boolean filters only (no text search) — $filter + $orderby is supported
|
||||||
|
if (hasBooleanFilters) {
|
||||||
|
try {
|
||||||
|
const filterConditions = [];
|
||||||
|
if (filterTerms.hasAttachments === true) filterConditions.push('hasAttachments eq true');
|
||||||
|
if (filterTerms.unreadOnly === true) filterConditions.push('isRead eq false');
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
$top: count,
|
||||||
|
$select: config.EMAIL_SELECT_FIELDS,
|
||||||
|
$orderby: 'receivedDateTime desc',
|
||||||
|
$filter: filterConditions.join(' and ')
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error(`Attempting boolean-filter-only search: ${params.$filter}`);
|
||||||
|
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, params);
|
||||||
|
console.error(`Boolean filter search found ${response.value?.length || 0} results`);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Boolean filter search failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Final fallback: recent emails
|
||||||
|
console.error("All search strategies exhausted, falling back to recent emails");
|
||||||
|
const basicParams = {
|
||||||
|
$top: count,
|
||||||
|
$select: config.EMAIL_SELECT_FIELDS,
|
||||||
|
$orderby: 'receivedDateTime desc'
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await callGraphAPI(accessToken, 'GET', endpoint, null, basicParams);
|
||||||
|
console.error(`Fallback to recent emails found ${response.value?.length || 0} results`);
|
||||||
|
|
||||||
|
response._searchFallback = true;
|
||||||
|
response._originalTerms = searchTerms;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a KQL query string for all provided search terms.
|
||||||
|
* The entire expression must be wrapped in outer double quotes for Graph API.
|
||||||
|
* Example: "subject:invoice from:john@example.com"
|
||||||
|
*/
|
||||||
|
function buildKqlQuery(searchTerms) {
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
if (searchTerms.subject) parts.push(`subject:${searchTerms.subject}`);
|
||||||
|
if (searchTerms.from) parts.push(`from:${searchTerms.from}`);
|
||||||
|
if (searchTerms.to) parts.push(`to:${searchTerms.to}`);
|
||||||
|
if (searchTerms.query) parts.push(searchTerms.query);
|
||||||
|
|
||||||
|
return `"${parts.join(' ')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a KQL query for a single field term.
|
||||||
|
* Example: "from:john@example.com"
|
||||||
|
*/
|
||||||
|
function buildSingleTermKql(term, value) {
|
||||||
|
if (term === 'query') {
|
||||||
|
return `"${value}"`;
|
||||||
|
}
|
||||||
|
return `"${term}:${value}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply boolean filter conditions to an in-memory array of emails.
|
||||||
|
* Used after $search results are returned (since $search + $filter is not supported).
|
||||||
|
*/
|
||||||
|
function applyClientSideFilters(emails, filterTerms) {
|
||||||
|
return emails.filter(email => {
|
||||||
|
if (filterTerms.hasAttachments === true && !email.hasAttachments) return false;
|
||||||
|
if (filterTerms.unreadOnly === true && email.isRead !== false) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format search results into a readable text format
|
||||||
|
* @param {object} response - The API response object
|
||||||
|
* @returns {object} - MCP response object
|
||||||
|
*/
|
||||||
|
function formatSearchResults(response) {
|
||||||
|
if (!response.value || response.value.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `No emails found matching your search criteria.`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format results
|
||||||
|
const emailList = response.value.map((email, index) => {
|
||||||
|
const sender = email.from?.emailAddress || { name: 'Unknown', address: 'unknown' };
|
||||||
|
const date = new Date(email.receivedDateTime).toLocaleString();
|
||||||
|
const readStatus = email.isRead ? '' : '[UNREAD] ';
|
||||||
|
const threadNote = email.conversationId ? `\nConversationID: ${email.conversationId}` : '';
|
||||||
|
|
||||||
|
return `${index + 1}. ${readStatus}${date} - From: ${sender.name} (${sender.address})\nSubject: ${email.subject}\nID: ${email.id}${threadNote}\n`;
|
||||||
|
}).join("\n");
|
||||||
|
|
||||||
|
// Add fallback warning if search had to give up
|
||||||
|
let additionalInfo = '';
|
||||||
|
if (response._searchFallback) {
|
||||||
|
additionalInfo = `\n⚠️ Search could not find matches for the specified criteria — showing recent emails instead.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Found ${response.value.length} emails:${additionalInfo}\n\n${emailList}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleSearchEmails;
|
||||||
120
email/send.js
Normal file
120
email/send.js
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* Send email functionality
|
||||||
|
*/
|
||||||
|
const config = require('../config');
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleSendEmail(args) {
|
||||||
|
const { to, cc, bcc, subject, body, importance = 'normal', saveToSentItems = true } = args;
|
||||||
|
|
||||||
|
// Validate required parameters
|
||||||
|
if (!to) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "Recipient (to) is required."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subject) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "Subject is required."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return {
|
||||||
|
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 {
|
||||||
|
emailAddress: {
|
||||||
|
address: email
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const ccRecipients = cc ? cc.split(',').map(email => {
|
||||||
|
email = email.trim();
|
||||||
|
return {
|
||||||
|
emailAddress: {
|
||||||
|
address: email
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}) : [];
|
||||||
|
|
||||||
|
const bccRecipients = bcc ? bcc.split(',').map(email => {
|
||||||
|
email = email.trim();
|
||||||
|
return {
|
||||||
|
emailAddress: {
|
||||||
|
address: email
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}) : [];
|
||||||
|
|
||||||
|
// Prepare email object
|
||||||
|
const emailObject = {
|
||||||
|
message: {
|
||||||
|
subject,
|
||||||
|
body: {
|
||||||
|
contentType: body.includes('<html') ? 'html' : 'text',
|
||||||
|
content: body
|
||||||
|
},
|
||||||
|
toRecipients,
|
||||||
|
ccRecipients: ccRecipients.length > 0 ? ccRecipients : undefined,
|
||||||
|
bccRecipients: bccRecipients.length > 0 ? bccRecipients : undefined,
|
||||||
|
importance
|
||||||
|
},
|
||||||
|
saveToSentItems
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make API call to send email
|
||||||
|
await callGraphAPI(accessToken, 'POST', 'me/sendMail', emailObject);
|
||||||
|
|
||||||
|
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`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 sending email: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleSendEmail;
|
||||||
124
folder/create.js
Normal file
124
folder/create.js
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
/**
|
||||||
|
* Create folder functionality
|
||||||
|
*/
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
const { getFolderIdByName } = require('../email/folder-utils');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create folder handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleCreateFolder(args) {
|
||||||
|
const folderName = args.name;
|
||||||
|
const parentFolder = args.parentFolder || '';
|
||||||
|
|
||||||
|
if (!folderName) {
|
||||||
|
return {
|
||||||
|
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);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: result.message
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 creating folder: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new mail folder
|
||||||
|
* @param {string} accessToken - Access token
|
||||||
|
* @param {string} folderName - Name of the folder to create
|
||||||
|
* @param {string} parentFolderName - Name of the parent folder (optional)
|
||||||
|
* @returns {Promise<object>} - Result object with status and message
|
||||||
|
*/
|
||||||
|
async function createMailFolder(accessToken, folderName, parentFolderName) {
|
||||||
|
try {
|
||||||
|
// Check if a folder with this name already exists
|
||||||
|
const existingFolder = await getFolderIdByName(accessToken, folderName);
|
||||||
|
if (existingFolder) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `A folder named "${folderName}" already exists.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If parent folder specified, find its ID
|
||||||
|
let endpoint = 'me/mailFolders';
|
||||||
|
if (parentFolderName) {
|
||||||
|
const parentId = await getFolderIdByName(accessToken, parentFolderName);
|
||||||
|
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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the folder
|
||||||
|
const folderData = {
|
||||||
|
displayName: folderName
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await callGraphAPI(
|
||||||
|
accessToken,
|
||||||
|
'POST',
|
||||||
|
endpoint,
|
||||||
|
folderData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response && response.id) {
|
||||||
|
const locationInfo = parentFolderName
|
||||||
|
? `inside "${parentFolderName}"`
|
||||||
|
: "at the root level";
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully created folder "${folderName}" ${locationInfo}.`,
|
||||||
|
folderId: response.id
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to create folder. The server didn't return a folder ID."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error creating folder "${folderName}": ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleCreateFolder;
|
||||||
78
folder/index.js
Normal file
78
folder/index.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* Folder management module for Outlook MCP server
|
||||||
|
*/
|
||||||
|
const handleListFolders = require('./list');
|
||||||
|
const handleCreateFolder = require('./create');
|
||||||
|
const handleMoveEmails = require('./move');
|
||||||
|
|
||||||
|
// Folder management tool definitions
|
||||||
|
const folderTools = [
|
||||||
|
{
|
||||||
|
name: "list-folders",
|
||||||
|
description: "Lists mail folders in your Outlook account",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
includeItemCounts: {
|
||||||
|
anyOf: [{ type: "boolean" }, { type: "string" }],
|
||||||
|
description: "Include counts of total and unread items"
|
||||||
|
},
|
||||||
|
includeChildren: {
|
||||||
|
anyOf: [{ type: "boolean" }, { type: "string" }],
|
||||||
|
description: "Include child folders in hierarchy"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: []
|
||||||
|
},
|
||||||
|
handler: handleListFolders
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create-folder",
|
||||||
|
description: "Creates a new mail folder",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
description: "Name of the folder to create"
|
||||||
|
},
|
||||||
|
parentFolder: {
|
||||||
|
type: "string",
|
||||||
|
description: "Optional parent folder name (default is root)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["name"]
|
||||||
|
},
|
||||||
|
handler: handleCreateFolder
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "move-emails",
|
||||||
|
description: "Moves emails from one folder to another",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
emailIds: {
|
||||||
|
type: "string",
|
||||||
|
description: "Comma-separated list of email IDs to move"
|
||||||
|
},
|
||||||
|
targetFolder: {
|
||||||
|
type: "string",
|
||||||
|
description: "Name of the folder to move emails to"
|
||||||
|
},
|
||||||
|
sourceFolder: {
|
||||||
|
type: "string",
|
||||||
|
description: "Optional name of the source folder (default is inbox)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["emailIds", "targetFolder"]
|
||||||
|
},
|
||||||
|
handler: handleMoveEmails
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
folderTools,
|
||||||
|
handleListFolders,
|
||||||
|
handleCreateFolder,
|
||||||
|
handleMoveEmails
|
||||||
|
};
|
||||||
264
folder/list.js
Normal file
264
folder/list.js
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
/**
|
||||||
|
* List folders functionality
|
||||||
|
*/
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List folders handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleListFolders(args) {
|
||||||
|
const includeItemCounts = args.includeItemCounts === true || args.includeItemCounts === 'true';
|
||||||
|
const includeChildren = args.includeChildren === true || args.includeChildren === 'true';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get access token
|
||||||
|
const accessToken = await ensureAuthenticated();
|
||||||
|
|
||||||
|
// Get all mail folders
|
||||||
|
const folders = await getAllFoldersHierarchy(accessToken, includeItemCounts);
|
||||||
|
|
||||||
|
// If including children, format as hierarchy
|
||||||
|
if (includeChildren) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: formatFolderHierarchy(folders, includeItemCounts)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Otherwise, format as flat list
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: formatFolderList(folders, includeItemCounts)
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} 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 folders: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all mail folders with hierarchy information
|
||||||
|
* @param {string} accessToken - Access token
|
||||||
|
* @param {boolean} includeItemCounts - Include item counts in response
|
||||||
|
* @returns {Promise<Array>} - Array of folder objects with hierarchy
|
||||||
|
*/
|
||||||
|
async function getAllFoldersHierarchy(accessToken, includeItemCounts) {
|
||||||
|
try {
|
||||||
|
// 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',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
$top: 100,
|
||||||
|
$select: selectFields
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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`,
|
||||||
|
null,
|
||||||
|
{ $select: selectFields }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const childFolders = await Promise.all(childFolderPromises);
|
||||||
|
const allChildFolders = childFolders.flat();
|
||||||
|
|
||||||
|
// Add top-level flag to parent folders
|
||||||
|
const topLevelFolders = response.value.map(folder => ({
|
||||||
|
...folder,
|
||||||
|
isTopLevel: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Combine all folders
|
||||||
|
return [...topLevelFolders, ...allChildFolders];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting all folders: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format folders as a flat list
|
||||||
|
* @param {Array} folders - Array of folder objects
|
||||||
|
* @param {boolean} includeItemCounts - Whether to include item counts
|
||||||
|
* @returns {string} - Formatted list
|
||||||
|
*/
|
||||||
|
function formatFolderList(folders, includeItemCounts) {
|
||||||
|
if (!folders || folders.length === 0) {
|
||||||
|
return "No folders found.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort folders alphabetically, with well-known folders first
|
||||||
|
const wellKnownFolderNames = ['Inbox', 'Drafts', 'Sent Items', 'Deleted Items', 'Junk Email', 'Archive'];
|
||||||
|
|
||||||
|
const sortedFolders = [...folders].sort((a, b) => {
|
||||||
|
// Well-known folders come first
|
||||||
|
const aIsWellKnown = wellKnownFolderNames.includes(a.displayName);
|
||||||
|
const bIsWellKnown = wellKnownFolderNames.includes(b.displayName);
|
||||||
|
|
||||||
|
if (aIsWellKnown && !bIsWellKnown) return -1;
|
||||||
|
if (!aIsWellKnown && bIsWellKnown) return 1;
|
||||||
|
|
||||||
|
if (aIsWellKnown && bIsWellKnown) {
|
||||||
|
// Sort well-known folders by their index in the array
|
||||||
|
return wellKnownFolderNames.indexOf(a.displayName) - wellKnownFolderNames.indexOf(b.displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort other folders alphabetically
|
||||||
|
return a.displayName.localeCompare(b.displayName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format each folder
|
||||||
|
const folderLines = sortedFolders.map(folder => {
|
||||||
|
let folderInfo = folder.displayName;
|
||||||
|
|
||||||
|
// Add parent folder info if available
|
||||||
|
if (folder.parentFolder) {
|
||||||
|
folderInfo += ` (in ${folder.parentFolder})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add item counts if requested
|
||||||
|
if (includeItemCounts) {
|
||||||
|
const unreadCount = folder.unreadItemCount || 0;
|
||||||
|
const totalCount = folder.totalItemCount || 0;
|
||||||
|
folderInfo += ` - ${totalCount} items`;
|
||||||
|
|
||||||
|
if (unreadCount > 0) {
|
||||||
|
folderInfo += ` (${unreadCount} unread)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return folderInfo;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `Found ${folders.length} folders:\n\n${folderLines.join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format folders as a hierarchical tree
|
||||||
|
* @param {Array} folders - Array of folder objects
|
||||||
|
* @param {boolean} includeItemCounts - Whether to include item counts
|
||||||
|
* @returns {string} - Formatted hierarchy
|
||||||
|
*/
|
||||||
|
function formatFolderHierarchy(folders, includeItemCounts) {
|
||||||
|
if (!folders || folders.length === 0) {
|
||||||
|
return "No folders found.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build folder hierarchy
|
||||||
|
const folderMap = new Map();
|
||||||
|
const rootFolders = [];
|
||||||
|
|
||||||
|
// First pass: create map of all folders
|
||||||
|
folders.forEach(folder => {
|
||||||
|
folderMap.set(folder.id, {
|
||||||
|
...folder,
|
||||||
|
children: []
|
||||||
|
});
|
||||||
|
|
||||||
|
if (folder.isTopLevel) {
|
||||||
|
rootFolders.push(folder.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second pass: build hierarchy
|
||||||
|
folders.forEach(folder => {
|
||||||
|
if (!folder.isTopLevel && folder.parentFolderId) {
|
||||||
|
const parent = folderMap.get(folder.parentFolderId);
|
||||||
|
if (parent) {
|
||||||
|
parent.children.push(folder.id);
|
||||||
|
} else {
|
||||||
|
// Fallback for orphaned folders
|
||||||
|
rootFolders.push(folder.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format hierarchy recursively
|
||||||
|
function formatSubtree(folderId, level = 0) {
|
||||||
|
const folder = folderMap.get(folderId);
|
||||||
|
if (!folder) return '';
|
||||||
|
|
||||||
|
const indent = ' '.repeat(level);
|
||||||
|
let line = `${indent}${folder.displayName}`;
|
||||||
|
|
||||||
|
// Add item counts if requested
|
||||||
|
if (includeItemCounts) {
|
||||||
|
const unreadCount = folder.unreadItemCount || 0;
|
||||||
|
const totalCount = folder.totalItemCount || 0;
|
||||||
|
line += ` - ${totalCount} items`;
|
||||||
|
|
||||||
|
if (unreadCount > 0) {
|
||||||
|
line += ` (${unreadCount} unread)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add children
|
||||||
|
const childLines = folder.children
|
||||||
|
.map(childId => formatSubtree(childId, level + 1))
|
||||||
|
.filter(line => line.length > 0)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return childLines.length > 0 ? `${line}\n${childLines}` : line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format all root folders
|
||||||
|
const formattedHierarchy = rootFolders
|
||||||
|
.map(folderId => formatSubtree(folderId))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `Folder Hierarchy:\n\n${formattedHierarchy}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleListFolders;
|
||||||
163
folder/move.js
Normal file
163
folder/move.js
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
/**
|
||||||
|
* Move emails functionality
|
||||||
|
*/
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
const { getFolderIdByName } = require('../email/folder-utils');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move emails handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleMoveEmails(args) {
|
||||||
|
const emailIds = args.emailIds || '';
|
||||||
|
const targetFolder = args.targetFolder || '';
|
||||||
|
const sourceFolder = args.sourceFolder || '';
|
||||||
|
|
||||||
|
if (!emailIds) {
|
||||||
|
return {
|
||||||
|
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",
|
||||||
|
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",
|
||||||
|
text: "No valid email IDs provided."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move emails
|
||||||
|
const result = await moveEmailsToFolder(accessToken, ids, targetFolder, sourceFolder);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: result.message
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 moving emails: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move emails to a folder
|
||||||
|
* @param {string} accessToken - Access token
|
||||||
|
* @param {Array<string>} emailIds - Array of email IDs to move
|
||||||
|
* @param {string} targetFolderName - Name of the target folder
|
||||||
|
* @param {string} sourceFolderName - Name of the source folder (optional)
|
||||||
|
* @returns {Promise<object>} - Result object with status and message
|
||||||
|
*/
|
||||||
|
async function moveEmailsToFolder(accessToken, emailIds, targetFolderName, sourceFolderName) {
|
||||||
|
try {
|
||||||
|
// Get the target folder ID
|
||||||
|
const targetFolderId = await getFolderIdByName(accessToken, targetFolderName);
|
||||||
|
if (!targetFolderId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Target folder "${targetFolderName}" not found. Please specify a valid folder name.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track successful and failed moves
|
||||||
|
const results = {
|
||||||
|
successful: [],
|
||||||
|
failed: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each email one by one to handle errors independently
|
||||||
|
for (const emailId of emailIds) {
|
||||||
|
try {
|
||||||
|
// Move the email
|
||||||
|
await callGraphAPI(
|
||||||
|
accessToken,
|
||||||
|
'POST',
|
||||||
|
`me/messages/${emailId}/move`,
|
||||||
|
{
|
||||||
|
destinationId: targetFolderId
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
results.successful.push(emailId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error moving email ${emailId}: ${error.message}`);
|
||||||
|
results.failed.push({
|
||||||
|
id: emailId,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate result message
|
||||||
|
let message = '';
|
||||||
|
|
||||||
|
if (results.successful.length > 0) {
|
||||||
|
message += `Successfully moved ${results.successful.length} email(s) to "${targetFolderName}".`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.failed.length > 0) {
|
||||||
|
if (message) message += '\n\n';
|
||||||
|
message += `Failed to move ${results.failed.length} email(s). Errors:`;
|
||||||
|
|
||||||
|
// Show first few errors with details
|
||||||
|
const maxErrors = Math.min(results.failed.length, 3);
|
||||||
|
for (let i = 0; i < maxErrors; i++) {
|
||||||
|
const failure = results.failed[i];
|
||||||
|
message += `\n- Email ${i+1}: ${failure.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
results
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in moveEmailsToFolder: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleMoveEmails;
|
||||||
204
index.js
Normal file
204
index.js
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Outlook MCP Server - Main entry point
|
||||||
|
*
|
||||||
|
* A Model Context Protocol server that provides access to
|
||||||
|
* Microsoft Outlook through the Microsoft Graph API.
|
||||||
|
*
|
||||||
|
* INSTRUCTIONS FOR AI MODELS:
|
||||||
|
* This server provides comprehensive Outlook integration with the following capabilities:
|
||||||
|
*
|
||||||
|
* 🔐 AUTHENTICATION (Required First):
|
||||||
|
* - Use `check-auth-status()` to verify authentication
|
||||||
|
* - Use `authenticate()` if not authenticated (follow the provided URL)
|
||||||
|
*
|
||||||
|
* 📧 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.
|
||||||
|
* - `send-email({ to, subject, body })` - Send new emails
|
||||||
|
*
|
||||||
|
* 💡 RECOMMENDED EMAIL WORKFLOW:
|
||||||
|
* 1. search-emails() or list-emails() → get conversationId from results
|
||||||
|
* 2. get-email-thread({ conversationId }) → read the full thread efficiently
|
||||||
|
*
|
||||||
|
* 📅 CALENDAR MANAGEMENT:
|
||||||
|
* - `list-events()` - List calendar events
|
||||||
|
* - `create-event({ subject, start, end })` - Create meetings
|
||||||
|
* - `decline-event()`, `cancel-event()` - Respond to invitations
|
||||||
|
*
|
||||||
|
* 📁 FOLDER MANAGEMENT:
|
||||||
|
* - `list-folders()` - List mail folders
|
||||||
|
* - `create-folder({ name })` - Create new folders
|
||||||
|
* - `move-emails({ emailIds, targetFolder })` - Organize emails
|
||||||
|
*
|
||||||
|
* 📋 EMAIL RULES:
|
||||||
|
* - `list-rules()` - List inbox rules
|
||||||
|
* - `create-rule({ name, conditions, actions })` - Automate email handling
|
||||||
|
*
|
||||||
|
* 💡 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
|
||||||
|
*
|
||||||
|
* 📖 For complete documentation, see MCP_TOOLS_GUIDE.md
|
||||||
|
*/
|
||||||
|
const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
|
||||||
|
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
||||||
|
const config = require('./config');
|
||||||
|
const { authTools } = require('./auth');
|
||||||
|
const { calendarTools } = require('./calendar');
|
||||||
|
const { emailTools } = require('./email');
|
||||||
|
const { folderTools } = require('./folder');
|
||||||
|
const { rulesTools } = require('./rules');
|
||||||
|
const { threadTool } = require('./tools/get-email-thread');
|
||||||
|
|
||||||
|
// Log startup information
|
||||||
|
console.error(`STARTING ${config.SERVER_NAME.toUpperCase()} MCP SERVER`);
|
||||||
|
console.error(`Test mode is ${config.USE_TEST_MODE ? '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'}`);
|
||||||
|
console.error(`[DEBUG] USE_TEST_MODE: ${process.env.USE_TEST_MODE ? 'SET' : 'NOT SET'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine all tools
|
||||||
|
const TOOLS = [
|
||||||
|
...authTools,
|
||||||
|
...calendarTools,
|
||||||
|
...emailTools,
|
||||||
|
...folderTools,
|
||||||
|
...rulesTools,
|
||||||
|
threadTool
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create server with tools capabilities
|
||||||
|
const server = new Server(
|
||||||
|
{ name: config.SERVER_NAME, version: config.SERVER_VERSION },
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
tools: TOOLS.reduce((acc, tool) => {
|
||||||
|
acc[tool.name] = {};
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle all requests
|
||||||
|
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}]`);
|
||||||
|
return {
|
||||||
|
protocolVersion: "2024-11-05",
|
||||||
|
capabilities: {
|
||||||
|
tools: TOOLS.reduce((acc, tool) => {
|
||||||
|
acc[tool.name] = {};
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
},
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: {
|
||||||
|
code: -32601,
|
||||||
|
message: `Tool not found: ${name}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in tools/call:`, error);
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: -32603,
|
||||||
|
message: `Error processing tool call: ${error.message}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For any other method, return method not found
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: -32601,
|
||||||
|
message: `Method not found: ${method}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error in fallbackRequestHandler:`, error);
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: -32603,
|
||||||
|
message: `Error processing request: ${error.message}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make the script executable
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.error('SIGTERM received, exiting.');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('exit', () => {
|
||||||
|
// Any cleanup needed when the main server exits
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
server.connect(transport)
|
||||||
|
.then(() => {
|
||||||
|
console.error(`${config.SERVER_NAME} connected and listening`);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`Connection error: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
3129
package-lock.json
generated
Normal file
3129
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"name": "outlook-mcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MCP server for Claude to access Outlook data via Microsoft Graph API",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"auth-server": "node outlook-auth-server.js",
|
||||||
|
"test-mode": "USE_TEST_MODE=true node index.js",
|
||||||
|
"inspect": "npx @modelcontextprotocol/inspector node index.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"claude",
|
||||||
|
"outlook",
|
||||||
|
"mcp",
|
||||||
|
"microsoft-graph",
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@azure/msal-node": "^2.16.2",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.1.0",
|
||||||
|
"dotenv": "^16.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@modelcontextprotocol/inspector": "^0.10.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
249
rules/create.js
Normal file
249
rules/create.js
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
/**
|
||||||
|
* Create rule functionality
|
||||||
|
*/
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
const { getFolderIdByName } = require('../email/folder-utils');
|
||||||
|
const { getInboxRules } = require('./list');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create rule handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleCreateRule(args) {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
fromAddresses,
|
||||||
|
containsSubject,
|
||||||
|
moveToFolder,
|
||||||
|
} = args;
|
||||||
|
// Coerce booleans and numbers — MCP hosts may send these as strings
|
||||||
|
const hasAttachments = args.hasAttachments === true || args.hasAttachments === 'true' ? true : undefined;
|
||||||
|
const markAsRead = args.markAsRead === true || args.markAsRead === 'true' ? true : undefined;
|
||||||
|
const isEnabled = args.isEnabled === false || args.isEnabled === 'false' ? false : true;
|
||||||
|
const sequence = args.sequence !== undefined ? parseInt(args.sequence, 10) : undefined;
|
||||||
|
|
||||||
|
// Add validation for sequence parameter
|
||||||
|
if (sequence !== undefined && (isNaN(sequence) || sequence < 1)) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "Sequence must be a positive number greater than zero."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "Rule name is required."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that at least one condition or action is specified
|
||||||
|
const hasCondition = fromAddresses || containsSubject || hasAttachments === true;
|
||||||
|
const hasAction = moveToFolder || markAsRead === true;
|
||||||
|
|
||||||
|
if (!hasCondition) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "At least one condition is required. Specify fromAddresses, containsSubject, or hasAttachments."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAction) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "At least one action is required. Specify moveToFolder or markAsRead."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get access token
|
||||||
|
const accessToken = await ensureAuthenticated();
|
||||||
|
|
||||||
|
// Create rule
|
||||||
|
const result = await createInboxRule(accessToken, {
|
||||||
|
name,
|
||||||
|
fromAddresses,
|
||||||
|
containsSubject,
|
||||||
|
hasAttachments,
|
||||||
|
moveToFolder,
|
||||||
|
markAsRead,
|
||||||
|
isEnabled,
|
||||||
|
sequence
|
||||||
|
});
|
||||||
|
|
||||||
|
let responseText = result.message;
|
||||||
|
|
||||||
|
// Add a tip about sequence if it wasn't provided
|
||||||
|
if (!sequence && !result.error) {
|
||||||
|
responseText += "\n\nTip: You can specify a 'sequence' parameter when creating rules to control their execution order. Lower sequence numbers run first.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: responseText
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 creating rule: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new inbox rule
|
||||||
|
* @param {string} accessToken - Access token
|
||||||
|
* @param {object} ruleOptions - Rule creation options
|
||||||
|
* @returns {Promise<object>} - Result object with status and message
|
||||||
|
*/
|
||||||
|
async function createInboxRule(accessToken, ruleOptions) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
fromAddresses,
|
||||||
|
containsSubject,
|
||||||
|
hasAttachments,
|
||||||
|
moveToFolder,
|
||||||
|
markAsRead,
|
||||||
|
isEnabled,
|
||||||
|
sequence
|
||||||
|
} = ruleOptions;
|
||||||
|
|
||||||
|
// Get existing rules to determine sequence if not provided
|
||||||
|
let ruleSequence = sequence;
|
||||||
|
if (!ruleSequence) {
|
||||||
|
try {
|
||||||
|
// Default to 100 if we can't get existing rules
|
||||||
|
ruleSequence = 100;
|
||||||
|
|
||||||
|
// Get existing rules to find highest sequence
|
||||||
|
const existingRules = await getInboxRules(accessToken);
|
||||||
|
if (existingRules && existingRules.length > 0) {
|
||||||
|
// Find the highest sequence
|
||||||
|
const highestSequence = Math.max(...existingRules.map(r => r.sequence || 0));
|
||||||
|
// Set new rule sequence to be higher
|
||||||
|
ruleSequence = Math.max(highestSequence + 1, 100);
|
||||||
|
console.error(`Auto-generated sequence: ${ruleSequence} (based on highest existing: ${highestSequence})`);
|
||||||
|
}
|
||||||
|
} catch (sequenceError) {
|
||||||
|
console.error(`Error determining rule sequence: ${sequenceError.message}`);
|
||||||
|
// Fall back to default value
|
||||||
|
ruleSequence = 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Using rule sequence: ${ruleSequence}`);
|
||||||
|
|
||||||
|
// Make sure sequence is a positive integer
|
||||||
|
ruleSequence = Math.max(1, Math.floor(ruleSequence));
|
||||||
|
|
||||||
|
// Build rule object with sequence
|
||||||
|
const rule = {
|
||||||
|
displayName: name,
|
||||||
|
isEnabled: isEnabled === true,
|
||||||
|
sequence: ruleSequence,
|
||||||
|
conditions: {},
|
||||||
|
actions: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add conditions
|
||||||
|
if (fromAddresses) {
|
||||||
|
// Parse email addresses
|
||||||
|
const emailAddresses = fromAddresses.split(',')
|
||||||
|
.map(email => email.trim())
|
||||||
|
.filter(email => email)
|
||||||
|
.map(email => ({
|
||||||
|
emailAddress: {
|
||||||
|
address: email
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (emailAddresses.length > 0) {
|
||||||
|
rule.conditions.fromAddresses = emailAddresses;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containsSubject) {
|
||||||
|
rule.conditions.subjectContains = [containsSubject];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAttachments === true) {
|
||||||
|
rule.conditions.hasAttachment = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add actions
|
||||||
|
if (moveToFolder) {
|
||||||
|
// Get folder ID
|
||||||
|
try {
|
||||||
|
const folderId = await getFolderIdByName(accessToken, moveToFolder);
|
||||||
|
if (!folderId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Target folder "${moveToFolder}" not found. Please specify a valid folder name.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.actions.moveToFolder = folderId;
|
||||||
|
} catch (folderError) {
|
||||||
|
console.error(`Error resolving folder "${moveToFolder}": ${folderError.message}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Error resolving folder "${moveToFolder}": ${folderError.message}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markAsRead === true) {
|
||||||
|
rule.actions.markAsRead = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the rule
|
||||||
|
const response = await callGraphAPI(
|
||||||
|
accessToken,
|
||||||
|
'POST',
|
||||||
|
'me/mailFolders/inbox/messageRules',
|
||||||
|
rule
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response && response.id) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully created rule "${name}" with sequence ${ruleSequence}.`,
|
||||||
|
ruleId: response.id
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Failed to create rule. The server didn't return a rule ID."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error creating rule: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = handleCreateRule;
|
||||||
176
rules/index.js
Normal file
176
rules/index.js
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
/**
|
||||||
|
* Email rules management module for Outlook MCP server
|
||||||
|
*/
|
||||||
|
const handleListRules = require('./list');
|
||||||
|
const handleCreateRule = require('./create');
|
||||||
|
|
||||||
|
// Import getInboxRules for the edit sequence tool
|
||||||
|
const { getInboxRules } = require('./list');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit rule sequence handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleEditRuleSequence(args) {
|
||||||
|
const { ruleName } = args;
|
||||||
|
const sequence = args.sequence !== undefined ? parseInt(args.sequence, 10) : undefined;
|
||||||
|
|
||||||
|
if (!ruleName) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "Rule name is required. Please specify the exact name of an existing rule."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sequence || isNaN(sequence) || sequence < 1) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: "A positive sequence number is required. Lower numbers run first (higher priority)."
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get access token
|
||||||
|
const accessToken = await ensureAuthenticated();
|
||||||
|
|
||||||
|
// Get all rules
|
||||||
|
const rules = await getInboxRules(accessToken);
|
||||||
|
|
||||||
|
// Find the rule by name
|
||||||
|
const rule = rules.find(r => r.displayName === ruleName);
|
||||||
|
if (!rule) {
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Rule with name "${ruleName}" not found.`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the rule sequence
|
||||||
|
const updateResult = await callGraphAPI(
|
||||||
|
accessToken,
|
||||||
|
'PATCH',
|
||||||
|
`me/mailFolders/inbox/messageRules/${rule.id}`,
|
||||||
|
{
|
||||||
|
sequence: sequence
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: `Successfully updated the sequence of rule "${ruleName}" to ${sequence}.`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 updating rule sequence: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules management tool definitions
|
||||||
|
const rulesTools = [
|
||||||
|
{
|
||||||
|
name: "list-rules",
|
||||||
|
description: "Lists inbox rules in your Outlook account",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
includeDetails: {
|
||||||
|
type: "boolean",
|
||||||
|
description: "Include detailed rule conditions and actions"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: []
|
||||||
|
},
|
||||||
|
handler: handleListRules
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create-rule",
|
||||||
|
description: "Creates a new inbox rule",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "string",
|
||||||
|
description: "Name of the rule to create"
|
||||||
|
},
|
||||||
|
fromAddresses: {
|
||||||
|
type: "string",
|
||||||
|
description: "Comma-separated list of sender email addresses for the rule"
|
||||||
|
},
|
||||||
|
containsSubject: {
|
||||||
|
type: "string",
|
||||||
|
description: "Subject text the email must contain"
|
||||||
|
},
|
||||||
|
hasAttachments: {
|
||||||
|
anyOf: [{ type: "boolean" }, { type: "string" }],
|
||||||
|
description: "Whether the rule applies to emails with attachments"
|
||||||
|
},
|
||||||
|
moveToFolder: {
|
||||||
|
type: "string",
|
||||||
|
description: "Name of the folder to move matching emails to"
|
||||||
|
},
|
||||||
|
markAsRead: {
|
||||||
|
anyOf: [{ type: "boolean" }, { type: "string" }],
|
||||||
|
description: "Whether to mark matching emails as read"
|
||||||
|
},
|
||||||
|
isEnabled: {
|
||||||
|
anyOf: [{ type: "boolean" }, { type: "string" }],
|
||||||
|
description: "Whether the rule should be enabled after creation (default: true)"
|
||||||
|
},
|
||||||
|
sequence: {
|
||||||
|
anyOf: [{ type: "number" }, { type: "string" }],
|
||||||
|
description: "Order in which the rule is executed (lower numbers run first, default: 100)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["name"]
|
||||||
|
},
|
||||||
|
handler: handleCreateRule
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "edit-rule-sequence",
|
||||||
|
description: "Changes the execution order of an existing inbox rule",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
ruleName: {
|
||||||
|
type: "string",
|
||||||
|
description: "Name of the rule to modify"
|
||||||
|
},
|
||||||
|
sequence: {
|
||||||
|
anyOf: [{ type: "number" }, { type: "string" }],
|
||||||
|
description: "New sequence value for the rule (lower numbers run first)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["ruleName", "sequence"]
|
||||||
|
},
|
||||||
|
handler: handleEditRuleSequence
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
rulesTools,
|
||||||
|
handleListRules,
|
||||||
|
handleCreateRule,
|
||||||
|
handleEditRuleSequence
|
||||||
|
};
|
||||||
202
rules/list.js
Normal file
202
rules/list.js
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
/**
|
||||||
|
* List rules functionality
|
||||||
|
*/
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List rules handler
|
||||||
|
* @param {object} args - Tool arguments
|
||||||
|
* @returns {object} - MCP response
|
||||||
|
*/
|
||||||
|
async function handleListRules(args) {
|
||||||
|
const includeDetails = args.includeDetails === true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get access token
|
||||||
|
const accessToken = await ensureAuthenticated();
|
||||||
|
|
||||||
|
// Get all inbox rules
|
||||||
|
const rules = await getInboxRules(accessToken);
|
||||||
|
|
||||||
|
// Format the rules based on detail level
|
||||||
|
const formattedRules = formatRulesList(rules, includeDetails);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: formattedRules
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} 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 rules: ${error.message}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all inbox rules
|
||||||
|
* @param {string} accessToken - Access token
|
||||||
|
* @returns {Promise<Array>} - Array of rule objects
|
||||||
|
*/
|
||||||
|
async function getInboxRules(accessToken) {
|
||||||
|
try {
|
||||||
|
const response = await callGraphAPI(
|
||||||
|
accessToken,
|
||||||
|
'GET',
|
||||||
|
'me/mailFolders/inbox/messageRules',
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.value || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting inbox rules: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format rules list for display
|
||||||
|
* @param {Array} rules - Array of rule objects
|
||||||
|
* @param {boolean} includeDetails - Whether to include detailed conditions and actions
|
||||||
|
* @returns {string} - Formatted rules list
|
||||||
|
*/
|
||||||
|
function formatRulesList(rules, includeDetails) {
|
||||||
|
if (!rules || rules.length === 0) {
|
||||||
|
return "No inbox rules found.\n\nTip: You can create rules using the 'create-rule' tool. Rules are processed in order of their sequence number (lower numbers are processed first).";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort rules by sequence to show execution order
|
||||||
|
const sortedRules = [...rules].sort((a, b) => {
|
||||||
|
return (a.sequence || 9999) - (b.sequence || 9999);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format rules based on detail level
|
||||||
|
if (includeDetails) {
|
||||||
|
// Detailed format
|
||||||
|
const detailedRules = sortedRules.map((rule, index) => {
|
||||||
|
// Format rule header with sequence
|
||||||
|
let ruleText = `${index + 1}. ${rule.displayName}${rule.isEnabled ? '' : ' (Disabled)'} - Sequence: ${rule.sequence || 'N/A'}`;
|
||||||
|
|
||||||
|
// Format conditions
|
||||||
|
const conditions = formatRuleConditions(rule);
|
||||||
|
if (conditions) {
|
||||||
|
ruleText += `\n Conditions: ${conditions}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format actions
|
||||||
|
const actions = formatRuleActions(rule);
|
||||||
|
if (actions) {
|
||||||
|
ruleText += `\n Actions: ${actions}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ruleText;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `Found ${rules.length} inbox rules (sorted by execution order):\n\n${detailedRules.join('\n\n')}\n\nRules are processed in order of their sequence number. You can change rule order using the 'edit-rule-sequence' tool.`;
|
||||||
|
} else {
|
||||||
|
// Simple format
|
||||||
|
const simpleRules = sortedRules.map((rule, index) => {
|
||||||
|
return `${index + 1}. ${rule.displayName}${rule.isEnabled ? '' : ' (Disabled)'} - Sequence: ${rule.sequence || 'N/A'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `Found ${rules.length} inbox rules (sorted by execution order):\n\n${simpleRules.join('\n')}\n\nTip: Use 'list-rules with includeDetails=true' to see more information about each rule.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format rule conditions for display
|
||||||
|
* @param {object} rule - Rule object
|
||||||
|
* @returns {string} - Formatted conditions
|
||||||
|
*/
|
||||||
|
function formatRuleConditions(rule) {
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
// From addresses
|
||||||
|
if (rule.conditions?.fromAddresses?.length > 0) {
|
||||||
|
const senders = rule.conditions.fromAddresses.map(addr => addr.emailAddress.address).join(', ');
|
||||||
|
conditions.push(`From: ${senders}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subject contains
|
||||||
|
if (rule.conditions?.subjectContains?.length > 0) {
|
||||||
|
conditions.push(`Subject contains: "${rule.conditions.subjectContains.join(', ')}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains body text
|
||||||
|
if (rule.conditions?.bodyContains?.length > 0) {
|
||||||
|
conditions.push(`Body contains: "${rule.conditions.bodyContains.join(', ')}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has attachment
|
||||||
|
if (rule.conditions?.hasAttachment === true) {
|
||||||
|
conditions.push('Has attachment');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Importance
|
||||||
|
if (rule.conditions?.importance) {
|
||||||
|
conditions.push(`Importance: ${rule.conditions.importance}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return conditions.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format rule actions for display
|
||||||
|
* @param {object} rule - Rule object
|
||||||
|
* @returns {string} - Formatted actions
|
||||||
|
*/
|
||||||
|
function formatRuleActions(rule) {
|
||||||
|
const actions = [];
|
||||||
|
|
||||||
|
// Move to folder
|
||||||
|
if (rule.actions?.moveToFolder) {
|
||||||
|
actions.push(`Move to folder: ${rule.actions.moveToFolder}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to folder
|
||||||
|
if (rule.actions?.copyToFolder) {
|
||||||
|
actions.push(`Copy to folder: ${rule.actions.copyToFolder}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as read
|
||||||
|
if (rule.actions?.markAsRead === true) {
|
||||||
|
actions.push('Mark as read');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark importance
|
||||||
|
if (rule.actions?.markImportance) {
|
||||||
|
actions.push(`Mark importance: ${rule.actions.markImportance}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward
|
||||||
|
if (rule.actions?.forwardTo?.length > 0) {
|
||||||
|
const recipients = rule.actions.forwardTo.map(r => r.emailAddress.address).join(', ');
|
||||||
|
actions.push(`Forward to: ${recipients}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
if (rule.actions?.delete === true) {
|
||||||
|
actions.push('Delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
handleListRules,
|
||||||
|
getInboxRules
|
||||||
|
};
|
||||||
164
tools/get-email-thread.js
Normal file
164
tools/get-email-thread.js
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get-email-thread tool
|
||||||
|
* Fetches a set of email message IDs, cleans each body, strips quoted history
|
||||||
|
* from each message, and returns a single clean chronological thread.
|
||||||
|
*
|
||||||
|
* This dramatically reduces token usage vs calling read-emails on a chain —
|
||||||
|
* each message no longer carries the full history of every prior reply.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const config = require('../config');
|
||||||
|
const { callGraphAPI } = require('../utils/graph-api');
|
||||||
|
const { ensureAuthenticated } = require('../auth');
|
||||||
|
const { buildThread } = require('../utils/threadBuilder');
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
async function fetchByConversationId(accessToken, conversationId) {
|
||||||
|
const allMessages = [];
|
||||||
|
let url = 'me/messages';
|
||||||
|
let params = {
|
||||||
|
$filter: `conversationId eq '${conversationId}'`,
|
||||||
|
$select: config.EMAIL_DETAIL_FIELDS,
|
||||||
|
$top: MAX_MESSAGES,
|
||||||
|
// NOTE: $orderby intentionally omitted — combining $filter on conversationId
|
||||||
|
// with $orderby causes a Graph API "InefficientFilter" 400 error.
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
return allMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for the get-email-thread tool.
|
||||||
|
* @param {object} args
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
async function handleGetEmailThread(args) {
|
||||||
|
const { ids, conversationId, subject } = args || {};
|
||||||
|
|
||||||
|
const hasIds = Array.isArray(ids) && ids.length > 0;
|
||||||
|
const hasConvId = typeof conversationId === 'string' && conversationId.trim().length > 0;
|
||||||
|
|
||||||
|
if (!hasIds && !hasConvId) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Provide either an ids array or a conversationId.' }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasIds && ids.length > MAX_MESSAGES) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Maximum ${MAX_MESSAGES} messages per thread request. ${ids.length} provided.` }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let accessToken;
|
||||||
|
try {
|
||||||
|
accessToken = await ensureAuthenticated();
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: "Authentication required. Please use the 'authenticate' tool first." }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = [];
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
if (hasConvId) {
|
||||||
|
// Auto-fetch entire conversation from all folders (inbox + sent + etc.)
|
||||||
|
try {
|
||||||
|
messages = await fetchByConversationId(accessToken, conversationId.trim());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[get-email-thread] conversationId fetch failed: ${err.message}`);
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Failed to fetch conversation: ${err.message}` }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`);
|
||||||
|
failCount++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
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 });
|
||||||
|
return { message, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[get-email-thread] Failed to fetch ${id}: ${err.message}`);
|
||||||
|
return { message: null, error: err.message };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
messages = results.filter(r => r.message).map(r => r.message);
|
||||||
|
failCount = results.filter(r => r.error).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: 'Could not retrieve any messages. Check IDs/conversationId and authentication.' }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const thread = buildThread(messages, subject);
|
||||||
|
const note = failCount > 0 ? `\n\n(Note: ${failCount} message(s) could not be fetched and are excluded.)` : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: thread + note }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadTool = {
|
||||||
|
name: 'get-email-thread',
|
||||||
|
description: 'Fetch a complete email thread and return it as a single clean deduplicated conversation, sorted chronologically. Each message shows only its unique new content — quoted prior replies are stripped, and signatures are deduplicated per sender. Prefer conversationId (from list-emails or search-emails results) to automatically include both inbox AND sent items in the thread. Fall back to ids when you only have specific message IDs without a conversationId.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
conversationId: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Conversation ID (from list-emails). Fetches the COMPLETE thread from all folders including Sent Items. Preferred over ids for full thread reconstruction.'
|
||||||
|
},
|
||||||
|
ids: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Array of specific message IDs to include (max 20). Use when you only have individual IDs and no conversationId.',
|
||||||
|
maxItems: MAX_MESSAGES
|
||||||
|
},
|
||||||
|
subject: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Optional: override the thread subject shown in the header'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: handleGetEmailThread
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { threadTool, handleGetEmailThread };
|
||||||
173
utils/bodyParser.js
Normal file
173
utils/bodyParser.js
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bodyParser.js
|
||||||
|
* Heuristic cleaner for email body text returned by Microsoft Graph API.
|
||||||
|
* Strips boilerplate noise without touching actual message content.
|
||||||
|
* All rules are based on observed real-world patterns from this mailbox.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pattern library — ordered by application sequence
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 1. HTML entities produced by Graph API's text/plain conversion
|
||||||
|
const HTML_ENTITIES = [
|
||||||
|
[/ /gi, ' '],
|
||||||
|
[/&/gi, '&'],
|
||||||
|
[/>/gi, '>'],
|
||||||
|
[/</gi, '<'],
|
||||||
|
[/"/gi, '"'],
|
||||||
|
[/'/gi, "'"],
|
||||||
|
];
|
||||||
|
|
||||||
|
// 2. External email caution banners — appear at start of body or inline
|
||||||
|
// Covers variations with/without "This email originated..." sentence
|
||||||
|
const CAUTION_BANNERS = [
|
||||||
|
// Full two-sentence form with bold/marker text
|
||||||
|
/CAUTION[\s\-–]*EXTERNAL\s+EMAIL\s*:.*?(?:content is safe\.?)/gis,
|
||||||
|
// Short form
|
||||||
|
/CAUTION\s*:?\s*This email originated from outside.*?(?:content is safe\.?)/gis,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 3. Legal boilerplate blocks — DISCLAIMER and CONFIDENTIALITY NOTICE
|
||||||
|
// These repeat on every reply in a chain. Match greedy to end of block.
|
||||||
|
const LEGAL_BLOCKS = [
|
||||||
|
// DISCLAIMER block (YMCA, others)
|
||||||
|
/DISCLAIMER\s*:.*?(?=DISCLAIMER\s*:|CONFIDENTIALITY\s*NOTICE\s*:|$)/gis,
|
||||||
|
// CONFIDENTIALITY NOTICE block (Prime HHCC, Farber & Lindley, others)
|
||||||
|
/CONFIDENTIALITY\s*NOTICE\s*:.*?(?=DISCLAIMER\s*:|CONFIDENTIALITY\s*NOTICE\s*:|$)/gis,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 4. Signature block delimiters — everything from a recognized sig opener onward
|
||||||
|
// Only applied when stripping signatures is explicitly requested (see exports).
|
||||||
|
const SIGNATURE_DELIMITERS = [
|
||||||
|
// Standard triple-dash separator
|
||||||
|
/^---\s*$/m,
|
||||||
|
// deRenzy signature pattern: name on one line, then IT@/Seton@ email lines
|
||||||
|
/^Seton Carmichael\s*\n(?:IT@|Seton@)/m,
|
||||||
|
/^Richard Priest\s*\n/m,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTML → plain text conversion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HTML email body to plain text, preserving line structure.
|
||||||
|
* Two-pass: structural tags → newlines first, then strip remaining tags.
|
||||||
|
* @param {string} html
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function htmlToText(html) {
|
||||||
|
let text = html;
|
||||||
|
// Mark Outlook signature boundaries before conversion.
|
||||||
|
// Outlook wraps signatures in id="Signature" (or id="x_Signature" on nested messages).
|
||||||
|
// We replace the opening tag with a sentinel [SIG] so callers can decide whether to
|
||||||
|
// keep or strip the signature after HTML→text conversion.
|
||||||
|
text = text.replace(/<div[^>]*\bid=["'][^"']*[Ss]ignature["'][^>]*>/gi, '\n[SIG]\n');
|
||||||
|
// Block-level tags that should become newlines
|
||||||
|
text = text.replace(/<br\s*\/?>/gi, '\n');
|
||||||
|
text = text.replace(/<\/(?:div|p|tr|li|blockquote|h[1-6])>/gi, '\n');
|
||||||
|
// Strip all remaining tags
|
||||||
|
text = text.replace(/<[^>]+>/g, '');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core cleaner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean a single email body string. Handles both HTML and plain text input.
|
||||||
|
* @param {string} body - Raw body text or HTML from Graph API
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @param {boolean} [opts.stripSignature=false] - Also strip trailing signature block
|
||||||
|
* @returns {string} Cleaned plain text
|
||||||
|
*/
|
||||||
|
function cleanBody(body, opts = {}) {
|
||||||
|
if (!body || typeof body !== 'string') return body || '';
|
||||||
|
|
||||||
|
let text = body;
|
||||||
|
|
||||||
|
// Step 1: Convert HTML to plain text if needed
|
||||||
|
if (/<html[\s>]/i.test(text) || /<body[\s>]/i.test(text) || /<div[\s>]/i.test(text)) {
|
||||||
|
text = htmlToText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Normalize HTML entities (may remain after tag stripping)
|
||||||
|
for (const [pattern, replacement] of HTML_ENTITIES) {
|
||||||
|
text = text.replace(pattern, replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Strip external caution banners
|
||||||
|
for (const pattern of CAUTION_BANNERS) {
|
||||||
|
text = text.replace(pattern, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Strip legal boilerplate blocks
|
||||||
|
for (const pattern of LEGAL_BLOCKS) {
|
||||||
|
text = text.replace(pattern, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Handle [SIG] sentinel (injected by htmlToText for id="Signature" divs)
|
||||||
|
if (opts.stripSignature) {
|
||||||
|
// Remove from [SIG] marker onward
|
||||||
|
text = text.replace(/\n?\[SIG\][\s\S]*/g, '');
|
||||||
|
} else {
|
||||||
|
// Keep signature content, just remove the marker itself
|
||||||
|
text = text.replace(/\[SIG\]\n?/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4b: Text-based signature delimiters (fallback for non-HTML emails)
|
||||||
|
if (opts.stripSignature) {
|
||||||
|
for (const delimiter of SIGNATURE_DELIMITERS) {
|
||||||
|
const match = text.search(delimiter);
|
||||||
|
if (match !== -1) {
|
||||||
|
text = text.slice(0, match);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Collapse runs of 3+ blank lines down to 2, trim edges
|
||||||
|
text = text.replace(/\n{3,}/g, '\n\n');
|
||||||
|
text = text.trim();
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean the body field of a Graph API email message object in-place.
|
||||||
|
* Returns the same object with body.content cleaned.
|
||||||
|
* Also cleans bodyPreview if present.
|
||||||
|
* @param {object} message - Graph API message object
|
||||||
|
* @param {object} [opts] - Same options as cleanBody
|
||||||
|
* @returns {object} The same message object, mutated
|
||||||
|
*/
|
||||||
|
function cleanMessage(message, opts = {}) {
|
||||||
|
if (!message) return message;
|
||||||
|
|
||||||
|
if (message.body && message.body.content) {
|
||||||
|
message.body.content = cleanBody(message.body.content, opts);
|
||||||
|
}
|
||||||
|
// bodyPreview is a short excerpt — just do entity decode and trim
|
||||||
|
if (message.bodyPreview) {
|
||||||
|
message.bodyPreview = cleanBody(message.bodyPreview, { stripSignature: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean an array of message objects.
|
||||||
|
* @param {object[]} messages
|
||||||
|
* @param {object} [opts]
|
||||||
|
* @returns {object[]}
|
||||||
|
*/
|
||||||
|
function cleanMessages(messages, opts = {}) {
|
||||||
|
if (!Array.isArray(messages)) return messages;
|
||||||
|
return messages.map(m => cleanMessage(m, opts));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { cleanBody, cleanMessage, cleanMessages };
|
||||||
119
utils/graph-api.js
Normal file
119
utils/graph-api.js
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
/**
|
||||||
|
* Microsoft Graph API helper functions
|
||||||
|
*/
|
||||||
|
const https = require('https');
|
||||||
|
const config = require('../config');
|
||||||
|
const mockData = require('./mock-data');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a request to the Microsoft Graph API
|
||||||
|
* @param {string} accessToken - The access token for authentication
|
||||||
|
* @param {string} method - HTTP method (GET, POST, etc.)
|
||||||
|
* @param {string} path - API endpoint path
|
||||||
|
* @param {object} data - Data to send for POST/PUT requests
|
||||||
|
* @param {object} queryParams - Query parameters
|
||||||
|
* @returns {Promise<object>} - The API response
|
||||||
|
*/
|
||||||
|
async function callGraphAPI(accessToken, method, path, data = null, queryParams = {}) {
|
||||||
|
// For test tokens, we'll simulate the API call
|
||||||
|
if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) {
|
||||||
|
console.error(`TEST MODE: Simulating ${method} ${path} API call`);
|
||||||
|
return mockData.simulateGraphAPIResponse(method, path, data, queryParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.error(`Making real API call: ${method} ${path}`);
|
||||||
|
|
||||||
|
// Encode path segments properly
|
||||||
|
const encodedPath = path.split('/')
|
||||||
|
.map(segment => encodeURIComponent(segment))
|
||||||
|
.join('/');
|
||||||
|
|
||||||
|
// Build query string from parameters with special handling for OData filters
|
||||||
|
let queryString = '';
|
||||||
|
if (Object.keys(queryParams).length > 0) {
|
||||||
|
// Handle $filter parameter specially to ensure proper URI encoding
|
||||||
|
const filter = queryParams.$filter;
|
||||||
|
if (filter) {
|
||||||
|
delete queryParams.$filter; // Remove from regular params
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query string with proper encoding for regular params
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(queryParams)) {
|
||||||
|
params.append(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryString = params.toString();
|
||||||
|
|
||||||
|
// Add filter parameter separately with proper encoding
|
||||||
|
if (filter) {
|
||||||
|
if (queryString) {
|
||||||
|
queryString += `&$filter=${encodeURIComponent(filter)}`;
|
||||||
|
} else {
|
||||||
|
queryString = `$filter=${encodeURIComponent(filter)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryString) {
|
||||||
|
queryString = '?' + queryString;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Query string: ${queryString}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString}`;
|
||||||
|
console.error(`Full URL: ${url}`);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(url, options, (res) => {
|
||||||
|
let responseData = '';
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
responseData += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
try {
|
||||||
|
const jsonResponse = JSON.parse(responseData);
|
||||||
|
resolve(jsonResponse);
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`Error parsing API response: ${error.message}`));
|
||||||
|
}
|
||||||
|
} else if (res.statusCode === 401) {
|
||||||
|
// Token expired or invalid
|
||||||
|
reject(new Error('UNAUTHORIZED'));
|
||||||
|
} else {
|
||||||
|
reject(new Error(`API call failed with status ${res.statusCode}: ${responseData}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(new Error(`Network error during API call: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data && (method === 'POST' || method === 'PATCH' || method === 'PUT')) {
|
||||||
|
req.write(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calling Graph API:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
callGraphAPI
|
||||||
|
};
|
||||||
145
utils/mock-data.js
Normal file
145
utils/mock-data.js
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
/**
|
||||||
|
* Mock data functions for test mode
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates Microsoft Graph API responses for testing
|
||||||
|
* @param {string} method - HTTP method
|
||||||
|
* @param {string} path - API path
|
||||||
|
* @param {object} data - Request data
|
||||||
|
* @param {object} queryParams - Query parameters
|
||||||
|
* @returns {object} - Simulated API response
|
||||||
|
*/
|
||||||
|
function simulateGraphAPIResponse(method, path, data, queryParams) {
|
||||||
|
console.error(`Simulating response for: ${method} ${path}`);
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
if (path.includes('messages') && !path.includes('sendMail')) {
|
||||||
|
// Simulate a successful email list/search response
|
||||||
|
if (path.includes('/messages/')) {
|
||||||
|
// Single email response
|
||||||
|
return {
|
||||||
|
id: "simulated-email-id",
|
||||||
|
subject: "Simulated Email Subject",
|
||||||
|
from: {
|
||||||
|
emailAddress: {
|
||||||
|
name: "Simulated Sender",
|
||||||
|
address: "sender@example.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toRecipients: [{
|
||||||
|
emailAddress: {
|
||||||
|
name: "Recipient Name",
|
||||||
|
address: "recipient@example.com"
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
ccRecipients: [],
|
||||||
|
bccRecipients: [],
|
||||||
|
receivedDateTime: new Date().toISOString(),
|
||||||
|
bodyPreview: "This is a simulated email preview...",
|
||||||
|
body: {
|
||||||
|
contentType: "text",
|
||||||
|
content: "This is the full content of the simulated email. Since we can't connect to the real Microsoft Graph API, we're returning this placeholder content instead."
|
||||||
|
},
|
||||||
|
hasAttachments: false,
|
||||||
|
importance: "normal",
|
||||||
|
isRead: false,
|
||||||
|
internetMessageHeaders: []
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Email list response
|
||||||
|
return {
|
||||||
|
value: [
|
||||||
|
{
|
||||||
|
id: "simulated-email-1",
|
||||||
|
subject: "Important Meeting Tomorrow",
|
||||||
|
from: {
|
||||||
|
emailAddress: {
|
||||||
|
name: "John Doe",
|
||||||
|
address: "john@example.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toRecipients: [{
|
||||||
|
emailAddress: {
|
||||||
|
name: "You",
|
||||||
|
address: "you@example.com"
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
ccRecipients: [],
|
||||||
|
receivedDateTime: new Date().toISOString(),
|
||||||
|
bodyPreview: "Let's discuss the project status...",
|
||||||
|
hasAttachments: false,
|
||||||
|
importance: "high",
|
||||||
|
isRead: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "simulated-email-2",
|
||||||
|
subject: "Weekly Report",
|
||||||
|
from: {
|
||||||
|
emailAddress: {
|
||||||
|
name: "Jane Smith",
|
||||||
|
address: "jane@example.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toRecipients: [{
|
||||||
|
emailAddress: {
|
||||||
|
name: "You",
|
||||||
|
address: "you@example.com"
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
ccRecipients: [],
|
||||||
|
receivedDateTime: new Date(Date.now() - 86400000).toISOString(), // Yesterday
|
||||||
|
bodyPreview: "Please find attached the weekly report...",
|
||||||
|
hasAttachments: true,
|
||||||
|
importance: "normal",
|
||||||
|
isRead: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "simulated-email-3",
|
||||||
|
subject: "Question about the project",
|
||||||
|
from: {
|
||||||
|
emailAddress: {
|
||||||
|
name: "Bob Johnson",
|
||||||
|
address: "bob@example.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toRecipients: [{
|
||||||
|
emailAddress: {
|
||||||
|
name: "You",
|
||||||
|
address: "you@example.com"
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
ccRecipients: [],
|
||||||
|
receivedDateTime: new Date(Date.now() - 172800000).toISOString(), // 2 days ago
|
||||||
|
bodyPreview: "I had a question about the timeline...",
|
||||||
|
hasAttachments: false,
|
||||||
|
importance: "normal",
|
||||||
|
isRead: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (path.includes('mailFolders')) {
|
||||||
|
// Simulate a mail folders response
|
||||||
|
return {
|
||||||
|
value: [
|
||||||
|
{ id: "inbox", displayName: "Inbox" },
|
||||||
|
{ id: "drafts", displayName: "Drafts" },
|
||||||
|
{ id: "sentItems", displayName: "Sent Items" },
|
||||||
|
{ id: "deleteditems", displayName: "Deleted Items" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (method === 'POST' && path.includes('sendMail')) {
|
||||||
|
// Simulate a successful email send
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, we don't have a simulation for this endpoint
|
||||||
|
console.error(`No simulation available for: ${method} ${path}`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
simulateGraphAPIResponse
|
||||||
|
};
|
||||||
221
utils/odata-helpers.js
Normal file
221
utils/odata-helpers.js
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
/**
|
||||||
|
* OData helper functions for Microsoft Graph API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes a string for use in OData queries
|
||||||
|
* @param {string} str - The string to escape
|
||||||
|
* @returns {string} - The escaped string
|
||||||
|
*/
|
||||||
|
function escapeODataString(str) {
|
||||||
|
if (!str) return str;
|
||||||
|
|
||||||
|
// Replace single quotes with double single quotes (OData escaping)
|
||||||
|
// And remove any special characters that could cause OData syntax errors
|
||||||
|
str = str.replace(/'/g, "''");
|
||||||
|
|
||||||
|
// Escape other potentially problematic characters
|
||||||
|
str = str.replace(/[\(\)\{\}\[\]\:\;\,\/\?\&\=\+\*\%\$\#\@\!\^]/g, '');
|
||||||
|
|
||||||
|
console.error(`Escaped OData string: '${str}'`);
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds an OData filter from filter conditions
|
||||||
|
* @param {Array<string>} conditions - Array of filter conditions
|
||||||
|
* @returns {string} - Combined OData filter expression
|
||||||
|
*/
|
||||||
|
function buildODataFilter(conditions) {
|
||||||
|
if (!conditions || conditions.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return conditions.join(' and ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets start of day for a given date
|
||||||
|
* @param {Date} date - The date
|
||||||
|
* @returns {Date} - Start of day
|
||||||
|
*/
|
||||||
|
function startOfDay(date) {
|
||||||
|
const start = new Date(date);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets end of day for a given date
|
||||||
|
* @param {Date} date - The date
|
||||||
|
* @returns {Date} - End of day
|
||||||
|
*/
|
||||||
|
function endOfDay(date) {
|
||||||
|
const end = new Date(date);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses date input (ISO string or relative date)
|
||||||
|
* @param {string} dateInput - Date string
|
||||||
|
* @returns {Date} - Parsed date
|
||||||
|
*/
|
||||||
|
function parseDate(dateInput) {
|
||||||
|
if (!dateInput) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now);
|
||||||
|
|
||||||
|
// Handle relative dates
|
||||||
|
const relativeMap = {
|
||||||
|
'today': today,
|
||||||
|
'yesterday': new Date(now.getTime() - 24*60*60*1000),
|
||||||
|
'tomorrow': new Date(now.getTime() + 24*60*60*1000),
|
||||||
|
'last7days': new Date(now.getTime() - 7*24*60*60*1000),
|
||||||
|
'last30days': new Date(now.getTime() - 30*24*60*60*1000),
|
||||||
|
'last90days': new Date(now.getTime() - 90*24*60*60*1000)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (relativeMap[dateInput.toLowerCase()]) {
|
||||||
|
return relativeMap[dateInput.toLowerCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ISO dates
|
||||||
|
const parsed = new Date(dateInput);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
throw new Error(`Invalid date format: ${dateInput}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes predefined date ranges
|
||||||
|
* @param {string} dateRange - Predefined range
|
||||||
|
* @returns {Object} - Object with from and to dates
|
||||||
|
*/
|
||||||
|
function processDateRange(dateRange) {
|
||||||
|
if (!dateRange) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now);
|
||||||
|
|
||||||
|
switch (dateRange.toLowerCase()) {
|
||||||
|
case 'today':
|
||||||
|
return {
|
||||||
|
from: startOfDay(today),
|
||||||
|
to: endOfDay(today)
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'yesterday': {
|
||||||
|
const yesterday = new Date(now.getTime() - 24*60*60*1000);
|
||||||
|
return {
|
||||||
|
from: startOfDay(yesterday),
|
||||||
|
to: endOfDay(yesterday)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'last7days':
|
||||||
|
return {
|
||||||
|
from: new Date(now.getTime() - 7*24*60*60*1000),
|
||||||
|
to: now
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'last30days':
|
||||||
|
return {
|
||||||
|
from: new Date(now.getTime() - 30*24*60*60*1000),
|
||||||
|
to: now
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'thisweek': {
|
||||||
|
const startOfWeek = new Date(today);
|
||||||
|
startOfWeek.setDate(today.getDate() - today.getDay());
|
||||||
|
return {
|
||||||
|
from: startOfDay(startOfWeek),
|
||||||
|
to: endOfDay(today)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'lastweek': {
|
||||||
|
const startOfLastWeek = new Date(today);
|
||||||
|
startOfLastWeek.setDate(today.getDate() - today.getDay() - 7);
|
||||||
|
const endOfLastWeek = new Date(startOfLastWeek);
|
||||||
|
endOfLastWeek.setDate(startOfLastWeek.getDate() + 6);
|
||||||
|
return {
|
||||||
|
from: startOfDay(startOfLastWeek),
|
||||||
|
to: endOfDay(endOfLastWeek)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'thismonth': {
|
||||||
|
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
return {
|
||||||
|
from: startOfDay(startOfMonth),
|
||||||
|
to: endOfDay(today)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'lastmonth': {
|
||||||
|
const startOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||||
|
const endOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||||
|
return {
|
||||||
|
from: startOfDay(startOfLastMonth),
|
||||||
|
to: endOfDay(endOfLastMonth)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown date range: ${dateRange}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds date filter conditions for OData queries
|
||||||
|
* @param {string} dateFrom - Start date
|
||||||
|
* @param {string} dateTo - End date
|
||||||
|
* @param {string} dateRange - Predefined range
|
||||||
|
* @returns {Array<string>} - Array of filter conditions
|
||||||
|
*/
|
||||||
|
function buildDateFilter(dateFrom, dateTo, dateRange) {
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (dateRange) {
|
||||||
|
const range = processDateRange(dateRange);
|
||||||
|
if (range) {
|
||||||
|
conditions.push(`receivedDateTime ge ${range.from.toISOString()}`);
|
||||||
|
conditions.push(`receivedDateTime le ${range.to.toISOString()}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (dateFrom) {
|
||||||
|
const fromDate = parseDate(dateFrom);
|
||||||
|
conditions.push(`receivedDateTime ge ${fromDate.toISOString()}`);
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
const toDate = parseDate(dateTo);
|
||||||
|
// If only date provided (no time), set to end of day
|
||||||
|
if (dateTo.length === 10) { // YYYY-MM-DD format
|
||||||
|
conditions.push(`receivedDateTime le ${endOfDay(toDate).toISOString()}`);
|
||||||
|
} else {
|
||||||
|
conditions.push(`receivedDateTime le ${toDate.toISOString()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Date filter error: ${error.message}`);
|
||||||
|
// Return empty conditions on error to avoid breaking the query
|
||||||
|
}
|
||||||
|
|
||||||
|
return conditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
escapeODataString,
|
||||||
|
buildODataFilter,
|
||||||
|
parseDate,
|
||||||
|
processDateRange,
|
||||||
|
buildDateFilter,
|
||||||
|
startOfDay,
|
||||||
|
endOfDay
|
||||||
|
};
|
||||||
160
utils/threadBuilder.js
Normal file
160
utils/threadBuilder.js
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { cleanBody } = require('./bodyParser');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* threadBuilder.js
|
||||||
|
* Reconstructs a clean, deduplicated email thread from a set of Graph API
|
||||||
|
* message objects. Each message is stripped down to only its unique new
|
||||||
|
* content — quoted prior messages are removed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Quote-stripping patterns
|
||||||
|
// Ordered from most specific to most general.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const QUOTE_BOUNDARIES = [
|
||||||
|
// Outlook-style attribution: "From: Name <email>\nSent: ..."
|
||||||
|
/^From\s*:\s*.+\n(?:Sent|Date)\s*:/im,
|
||||||
|
// Outlook-style with leading whitespace/non-breaking spaces
|
||||||
|
/^\s*From\s*:\s*.+\n\s*(?:Sent|Date)\s*:/im,
|
||||||
|
// Gmail/web client: "On Mon, Mar 13, 2026 at 8:32 AM, Name <email> wrote:"
|
||||||
|
/^On\s+.{5,80}wrote\s*:\s*$/im,
|
||||||
|
// Simple "wrote:" attribution line
|
||||||
|
/^.{0,100}<.+@.+>\s+wrote\s*:/im,
|
||||||
|
// Forwarded message header block
|
||||||
|
/^-{3,}\s*(?:Forwarded|Original)\s+[Mm]essage\s*-{3,}/im,
|
||||||
|
// Lines starting with > (standard quote marker)
|
||||||
|
/^>+\s/m,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core functions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract only the new/unique content from a single message body,
|
||||||
|
* stripping all quoted prior messages.
|
||||||
|
* @param {string} body - Cleaned body text
|
||||||
|
* @returns {string} Unique message content only
|
||||||
|
*/
|
||||||
|
function extractUniqueContent(body) {
|
||||||
|
if (!body) return '';
|
||||||
|
|
||||||
|
let earliestBoundary = body.length;
|
||||||
|
|
||||||
|
for (const pattern of QUOTE_BOUNDARIES) {
|
||||||
|
const match = body.search(pattern);
|
||||||
|
if (match !== -1 && match < earliestBoundary) {
|
||||||
|
earliestBoundary = match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique = body.slice(0, earliestBoundary).trim();
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a single message as a clean thread entry.
|
||||||
|
* @param {object} msg - Graph API message object (already body-cleaned)
|
||||||
|
* @param {number} index - 1-based position in thread
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatThreadEntry(msg, index) {
|
||||||
|
const from = msg.from?.emailAddress
|
||||||
|
? `${msg.from.emailAddress.name || ''} <${msg.from.emailAddress.address}>`.trim()
|
||||||
|
: 'Unknown';
|
||||||
|
|
||||||
|
const to = (msg.toRecipients || [])
|
||||||
|
.map(r => r.emailAddress?.name || r.emailAddress?.address || '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ') || 'Unknown';
|
||||||
|
|
||||||
|
const cc = (msg.ccRecipients || [])
|
||||||
|
.map(r => r.emailAddress?.name || r.emailAddress?.address || '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
const date = msg.receivedDateTime
|
||||||
|
? new Date(msg.receivedDateTime).toLocaleString('en-US', {
|
||||||
|
month: 'numeric', day: 'numeric', year: 'numeric',
|
||||||
|
hour: 'numeric', minute: '2-digit', hour12: true
|
||||||
|
})
|
||||||
|
: 'Unknown date';
|
||||||
|
|
||||||
|
const bodyText = msg.body?.content || msg.bodyPreview || '';
|
||||||
|
const unique = extractUniqueContent(bodyText);
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`[${index}] ${date}`,
|
||||||
|
`From: ${from}`,
|
||||||
|
`To: ${to}`,
|
||||||
|
];
|
||||||
|
if (cc) lines.push(`CC: ${cc}`);
|
||||||
|
if (msg.hasAttachments) lines.push('Attachments: Yes');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(unique || '(no unique content)');
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a clean deduplicated thread from an array of Graph API message objects.
|
||||||
|
* Messages are sorted chronologically and each is stripped to unique content only.
|
||||||
|
*
|
||||||
|
* @param {object[]} messages - Array of Graph API message objects with body.content populated
|
||||||
|
* @param {string} [subject] - Thread subject (inferred from first message if omitted)
|
||||||
|
* @returns {string} Formatted thread as a single string
|
||||||
|
*/
|
||||||
|
function buildThread(messages, subject) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) {
|
||||||
|
return '(no messages)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort chronologically
|
||||||
|
const sorted = [...messages].sort((a, b) => {
|
||||||
|
const ta = a.receivedDateTime ? new Date(a.receivedDateTime).getTime() : 0;
|
||||||
|
const tb = b.receivedDateTime ? new Date(b.receivedDateTime).getTime() : 0;
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Infer subject if not provided
|
||||||
|
const threadSubject = subject
|
||||||
|
|| sorted[0]?.subject
|
||||||
|
|| '(no subject)';
|
||||||
|
|
||||||
|
// Strip Re:/Fw: prefix for display
|
||||||
|
const displaySubject = threadSubject.replace(/^(Re|Fwd?)\s*:\s*/i, '').trim();
|
||||||
|
|
||||||
|
const header = [
|
||||||
|
`Thread: ${displaySubject}`,
|
||||||
|
`Messages: ${sorted.length}`,
|
||||||
|
'='.repeat(60),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// Track which senders have already had their signature included.
|
||||||
|
// First message from each sender keeps the signature; repeats get it stripped.
|
||||||
|
const seenSenders = new Set();
|
||||||
|
const entries = sorted
|
||||||
|
.map((msg, i) => {
|
||||||
|
const senderEmail = msg.from?.emailAddress?.address?.toLowerCase() || '';
|
||||||
|
const isRepeat = seenSenders.has(senderEmail);
|
||||||
|
if (senderEmail) seenSenders.add(senderEmail);
|
||||||
|
|
||||||
|
const cleanedMsg = { ...msg };
|
||||||
|
if (cleanedMsg.body?.content) {
|
||||||
|
cleanedMsg.body = {
|
||||||
|
...cleanedMsg.body,
|
||||||
|
content: cleanBody(cleanedMsg.body.content, { stripSignature: isRepeat }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return formatThreadEntry(cleanedMsg, i + 1);
|
||||||
|
})
|
||||||
|
.join('\n\n' + '-'.repeat(40) + '\n\n');
|
||||||
|
|
||||||
|
return header + entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildThread, extractUniqueContent };
|
||||||
Loading…
Reference in a new issue