diff --git a/SKILL.md b/SKILL.md index e6d15f1..37e11ed 100644 --- a/SKILL.md +++ b/SKILL.md @@ -56,7 +56,8 @@ Clear stored credentials. No parameters. #### `get_emails` -Get emails from a folder. +Get emails from a folder. Supports optional text search via +`query`/`queryScope`. | Parameter | Type | Default | Description | | ------------- | ------- | --------- | ----------------------------------------------------------------------------------- | @@ -66,6 +67,8 @@ Get emails from a folder. | `includeBody` | boolean | `false` | If `true`, fetches full email body | | `unreadOnly` | boolean | `false` | Only unread emails | | `idsOnly` | boolean | `false` | Return only IDs + dates + subjects (faster, higher limit) | +| `query` | string | — | Text to search for within the folder | +| `queryScope` | enum | `"all"` | Scope: `all`, `subject`, `body`, `from` (only when `query` is set) | #### `get_email` @@ -75,18 +78,6 @@ Get a single email with full body and details. | --------- | ------ | -------- | ---------------------------- | | `itemId` | string | yes | Exchange ItemId of the email | -#### `search_emails` - -Search emails by text. - -| Parameter | Type | Default | Description | -| ------------- | ------ | -------- | ------------------------------------------- | -| `query` | string | — | Text to search for | -| `folderId` | string | optional | Folder ID to limit search; omitted = all | -| `limit` | number | `10` | Max emails (max 50) | -| `offset` | number | `0` | Pagination offset | -| `searchScope` | enum | `"all"` | Scope: `all`, `subject`, `body`, `from` | - #### `mark_email_read` Mark emails as read or unread. diff --git a/ews_client.ts b/ews_client.ts index 41634c9..fe90b3c 100644 --- a/ews_client.ts +++ b/ews_client.ts @@ -251,8 +251,7 @@ export class EwsClient { } = options; const isDistinguished = - DISTINGUISHED_FOLDERS[folderId.toLowerCase()] !== undefined - || ["msgfolderroot"].includes(folderId.toLowerCase()); + DISTINGUISHED_FOLDERS[folderId.toLowerCase()] !== undefined; const folderIdXml = isDistinguished ? `` diff --git a/tools/email.ts b/tools/email.ts index 6c82f93..c5c6825 100644 --- a/tools/email.ts +++ b/tools/email.ts @@ -32,27 +32,92 @@ export function registerEmailTools(server: McpServer): void { idsOnly: z.boolean().default(false).describe( "If True, return only item IDs and dates (max limit 500)", ), + query: z.string().optional().describe( + "Optional text to search for within the folder", + ), + queryScope: z.enum(["all", "subject", "body", "from"]).default("all") + .describe("Where to search (only used when query is set)"), }), }, - async ({ folder, limit, offset, includeBody, unreadOnly, idsOnly }) => { + async ( + { + folder, + limit, + offset, + includeBody, + unreadOnly, + idsOnly, + query, + queryScope, + }, + ) => { try { const client = getClient(); const maxLimit = idsOnly ? 500 : 50; if (limit > maxLimit) limit = maxLimit; + offset = Math.max(0, offset); const folderId = await client.getFolderId(folder); const baseShape = idsOnly ? "IdOnly" : "AllProperties"; - let restriction = ""; + if (query && !query.trim()) query = undefined; + + const restrictions: string[] = []; + if (unreadOnly) { - restriction = `\ - + restrictions.push(`\ - + `); + } + + if (query) { + function containsExpression(fieldUri: string, value: string): string { + return `\ + + + /g, + ">", + ).replace(/"/g, """) + }"/> + `; + } + + const fieldUriMap: Record = { + subject: "item:Subject", + body: "item:Body", + from: "message:From", + }; + + if (queryScope === "all") { + restrictions.push(`\ + + ${containsExpression("item:Subject", query)} + ${containsExpression("item:Body", query)} + `); + } else { + const fieldUri = fieldUriMap[queryScope]; + restrictions.push(containsExpression(fieldUri, query)); + } + } + + let restriction = ""; + if (restrictions.length === 1) { + restriction = `\ + + ${restrictions[0]} + `; + } else if (restrictions.length > 1) { + restriction = `\ + + + ${restrictions.join("\n")} + `; } @@ -139,127 +204,6 @@ export function registerEmailTools(server: McpServer): void { }, ); - server.registerTool( - "search_emails", - { - description: "Search emails by text across one or all folders", - inputSchema: z.object({ - query: z.string().describe("The text to search for"), - folderId: z.string().optional().describe( - "Optional folder ID to limit the search. When omitted, searches all mail folders", - ), - limit: z.number().default(10).describe( - "Maximum number of emails to return (default 10, max 50)", - ), - offset: z.number().default(0).describe( - "Number of emails to skip for pagination", - ), - searchScope: z.enum(["all", "subject", "body", "from"]).default("all") - .describe("Where to search"), - }), - }, - async ({ query, folderId, limit, offset, searchScope }) => { - try { - const client = getClient(); - - if (!query.trim()) { - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: "query must not be empty", - results: [], - }), - }], - }; - } - - limit = Math.max(1, Math.min(limit, 50)); - offset = Math.max(0, offset); - - const fieldUriMap: Record = { - subject: "item:Subject", - body: "item:Body", - from: "message:From", - }; - - function containsExpression(fieldUri: string, value: string): string { - return `\ - - - /g, - ">", - ).replace(/"/g, """) - }"/> - `; - } - - let restriction: string; - if (searchScope === "all") { - restriction = `\ - - - ${containsExpression("item:Subject", query)} - ${containsExpression("item:Body", query)} - - `; - } else { - const fieldUri = fieldUriMap[searchScope]; - if (!fieldUri) { - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - error: `unsupported search_scope: ${searchScope}`, - results: [], - }), - }], - }; - } - restriction = `\ - - ${containsExpression(fieldUri, query)} - `; - } - - const targetFolder = folderId || "msgfolderroot"; - const traversal = folderId ? "Shallow" : "Deep"; - - const items = await client.findItems(targetFolder, { - limit, - offset, - restriction, - traversal, - }); - const results = formatSearchResults(items, client); - - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ - query, - searchScope, - folderId: folderId || "all", - limit, - offset, - totalResults: results.length, - results, - }), - }], - }; - } catch (error: any) { - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ error: error.message ?? String(error) }), - }], - }; - } - }, - ); - server.registerTool( "mark_email_read", { @@ -416,28 +360,3 @@ export function registerEmailTools(server: McpServer): void { }, ); } - -function formatSearchResults( - items: any[], - client: EwsClient, -): any[] { - const results: any[] = []; - for (const item of items) { - const summary = client.extractEmailSummary(item); - - const bodyHtml = item.Body?.Value ?? item.Body?.["#text"] ?? ""; - const bodyType = item.Body?.["@_BodyType"] ?? "HTML"; - let bodyPreview = ""; - if (bodyType === "HTML" && bodyHtml) { - bodyPreview = bodyHtml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ") - .trim().slice(0, 200); - } else { - bodyPreview = bodyHtml.slice(0, 200); - } - - summary.preview = bodyPreview; - - results.push(summary); - } - return results; -}