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:
Seton Carmichael 2026-06-21 19:39:31 -04:00
commit a7886b5b2b
37 changed files with 7953 additions and 0 deletions

13
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

32
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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 = [
[/&nbsp;/gi, ' '],
[/&amp;/gi, '&'],
[/&gt;/gi, '>'],
[/&lt;/gi, '<'],
[/&quot;/gi, '"'],
[/&#39;/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
View 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
View 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
View 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
View 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 };