diff --git a/SKILL.md b/SKILL.md deleted file mode 100644 index 3ea570b..0000000 --- a/SKILL.md +++ /dev/null @@ -1,179 +0,0 @@ ---- -name: exchange-mcp -description: >- - Use this skill when the user talks about - email, calendar, contacts, Exchange, EWS, corporate mail, - or scheduling meetings. ---- - -# exchange-mcp — MCP server for Microsoft Exchange - -This MCP server provides access to **Microsoft Exchange Server** via **Exchange -Web Services (EWS)** with NTLM authentication. Supports email, calendar, -contacts, and free/busy scheduling. - -**Use this server when the user talks about:** - -- Email, inbox, sent items, drafts -- Calendar, events, meetings, availability -- Contacts, people, colleagues, employees -- Attachments, files in emails -- Exchange, EWS, corporate mail - -**Does not support:** Microsoft 365 / Exchange Online (OAuth), IMAP/POP3/SMTP, -Gmail, Outlook.com. - ---- - -## Tools - -### Auth - -#### `login` - -Authenticate to Exchange EWS via NTLM. On first use, all fields except `domain` -are required. On subsequent logins, only `password` is needed — previously saved -`serverUrl`, `email`, `username`, and `domain` are reused automatically. - -If the user asks to log in and only provides a password, this is likely a -_repeated_ login — the previously saved config will be reused automatically. -Just call the `login` tool with the password alone. - -| Parameter | Type | Required | Description | -| ----------- | ------ | -------- | ------------------------------------------------ | -| `serverUrl` | string | no | EWS server URL (e.g. `https://mail.example.com`) | -| `email` | string | no | Email address | -| `username` | string | no | NTLM username | -| `password` | string | yes | NTLM password | -| `domain` | string | no | NTLM domain (default: `corp`) | - -#### `check_session` - -Check if the current session is authenticated. No parameters. Returns -`{authenticated, email, serverUrl}`. - -#### `logout` - -Clear stored credentials. No parameters. - ---- - -### Email - -#### `get_emails` - -Get emails from a folder. Supports optional text search via -`query`/`queryScope`. - -| Parameter | Type | Default | Description | -| ------------- | ------- | --------- | ----------------------------------------------------------------------------------- | -| `folder` | string | `"Inbox"` | Folder name (Inbox, Sent, Drafts, Deleted, Junk, or custom; supports Russian names) | -| `limit` | number | `10` | Max emails (max 50; max 500 if `idsOnly=true`) | -| `offset` | number | `0` | Pagination offset | -| `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` - -Get a single email with full body and details. - -| Parameter | Type | Required | Description | -| --------- | ------ | -------- | ---------------------------- | -| `itemId` | string | yes | Exchange ItemId of the email | - -#### `mark_email_read` - -Mark emails as read or unread. - -| Parameter | Type | Default | Description | -| --------- | -------- | ------- | ------------------------------- | -| `itemIds` | string[] | — | List of Exchange ItemIds | -| `isRead` | boolean | `true` | `true` = read, `false` = unread | - -#### `download_attachments` - -Download all file attachments from an email to disk. - -| Parameter | Type | Default | Description | -| -------------- | ------ | -------------------- | --------------- | -| `itemId` | string | — | Exchange ItemId | -| `targetFolder` | string | `"/tmp/attachments"` | Local folder | - ---- - -### Folders - -#### `get_folders` - -List mailbox folders. - -| Parameter | Type | Default | Description | -| ---------------- | ------- | ----------------- | ----------------------------------------------------------------- | -| `parentFolderId` | string | `"msgfolderroot"` | Parent folder (supports distinguished names: `inbox`, `calendar`) | -| `recursive` | boolean | `false` | Recursively traverse subfolders | - ---- - -### Calendar - -#### `get_calendar_events` - -Get calendar events within a date range. - -| Parameter | Type | Required | Description | -| ------------- | ------- | -------- | ----------------------------------------- | -| `startDate` | string | yes | Start (`YYYY-MM-DD`) | -| `endDate` | string | yes | End (`YYYY-MM-DD`) | -| `includeBody` | boolean | `false` | Full details (organizer, attendees, body) | - -#### `download_event_attachments` - -Download attachments from a calendar event. Same parameters as -`download_attachments`. - ---- - -### Availability - -#### `find_free_time` - -Find free time slots in your calendar. - -| Parameter | Type | Default | Description | -| ----------------- | ------ | ----------- | -------------------------- | -| `startDate` | string | — | Start (`YYYY-MM-DD`) | -| `endDate` | string | = startDate | End | -| `durationMinutes` | number | `30` | Minimum slot duration | -| `startHour` | number | `9` | Work day start hour (0-23) | -| `endHour` | number | `18` | Work day end hour (0-23) | - -#### `find_meeting_time` - -Find common free time for multiple people. - -| Parameter | Type | Default | Description | -| ----------------- | ------ | ----------- | --------------------------------- | -| `emails` | string | — | Attendee emails (comma-separated) | -| `startDate` | string | — | Start | -| `endDate` | string | = startDate | End | -| `durationMinutes` | number | `30` | Minimum slot duration | -| `startHour` | number | `9` | Work day start hour | -| `endHour` | number | `18` | Work day end hour | - ---- - -### People - -#### `find_person` - -Search for people in the corporate directory (Active Directory). - -| Parameter | Type | Required | Description | -| --------- | ------ | -------- | ----------------------- | -| `query` | string | yes | Name, email, or keyword | - -Returns: name, email, job title, department, company, office, phone, manager, -direct reports. diff --git a/ews_client.ts b/client/ews_client.ts similarity index 59% rename from ews_client.ts rename to client/ews_client.ts index fe90b3c..e55b33f 100644 --- a/ews_client.ts +++ b/client/ews_client.ts @@ -1,36 +1,19 @@ import { XMLParser } from "fast-xml-parser"; -import fs from "node:fs"; import https from "node:https"; import { createRequire } from "node:module"; -import path from "node:path"; import { promisify } from "node:util"; -import type { Email } from "./types/email.ts"; -import type { Folder } from "./types/folder.ts"; -import type { LoginConfig } from "./types/login_config.ts"; - -let inMemoryPassword: string | null = null; -let dataDir: string = "./data"; - -export function initDataDir(dir: string): void { - dataDir = path.resolve(dir); - fs.mkdirSync(dataDir, { recursive: true }); -} - -export function setPassword(password: string): void { - inMemoryPassword = password; -} - -export function getPassword(): string | null { - return inMemoryPassword; -} - -export function hasPassword(): boolean { - return inMemoryPassword !== null; -} - -export function clearPassword(): void { - inMemoryPassword = null; -} +import { extractEmail, type Email } from "../utils/extract_email.ts"; +import { extractFolders, type Folder } from "../utils/extract_folder.ts"; +import { initDataDir } from "../utils/data.ts"; +import { + loadConfig, + saveConfig, + clearConfig, + setPassword, + getPassword, + hasPassword, + type LoginConfig, +} from "../utils/login.ts"; const ntlm: any = createRequire(import.meta.url)("httpntlm"); const postAsync = promisify(ntlm.post.bind(ntlm)); @@ -43,13 +26,6 @@ const AGENT = new https.Agent({ secureOptions: SSL_OP_LEGACY_SERVER_CONNECT, }); -const PARSER = new XMLParser({ - ignoreAttributes: false, - attributeNamePrefix: "@_", - removeNSPrefix: true, - textNodeName: "#text", -}); - const DISTINGUISHED_FOLDERS: Record = { inbox: "inbox", "входящие": "inbox", @@ -78,30 +54,12 @@ ${body} `; } -function configFilePath(): string { - return path.join(dataDir, "config.json"); -} - -export function loadConfig(): LoginConfig { - const filePath = configFilePath(); - if (!fs.existsSync(filePath)) { - throw new Error("Not logged in. Use the login tool first."); - } - return JSON.parse(fs.readFileSync(filePath, "utf-8")) as LoginConfig; -} - -export function saveConfig(config: LoginConfig): void { - const filePath = configFilePath(); - fs.writeFileSync(filePath, JSON.stringify(config, null, 2), "utf-8"); -} - -export function clearConfig(): void { - const filePath = configFilePath(); - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - clearPassword(); -} +const PARSER = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + removeNSPrefix: true, + textNodeName: "#text", +}); export class EwsClient { private config: LoginConfig; @@ -110,10 +68,7 @@ export class EwsClient { constructor(config: LoginConfig, password?: string) { this.config = config; this.ewsUrl = `${config.serverUrl.replace(/\/+$/, "")}/EWS/Exchange.asmx`; - - if (password) { - setPassword(password); - } + if (password) setPassword(password); } private get domain(): string { @@ -144,8 +99,7 @@ export class EwsClient { throw new Error(`NTLM request failed, status=${res.statusCode}`); } - const parsed = PARSER.parse(res.body); - return parsed; + return PARSER.parse(res.body); } private extractResponseMessages(data: any): any[] { @@ -176,7 +130,7 @@ export class EwsClient { async getFolderId(folderName: string): Promise { const lower = folderName.toLowerCase(); - const distinguished = DISTINGUISHED_FOLDERS[lower]; + const distinguished = (DISTINGUISHED_FOLDERS as Record)[lower]; if (distinguished) { const soap = buildSoapEnvelope(`\ @@ -251,7 +205,7 @@ export class EwsClient { } = options; const isDistinguished = - DISTINGUISHED_FOLDERS[folderId.toLowerCase()] !== undefined; + (DISTINGUISHED_FOLDERS as Record)[folderId.toLowerCase()] !== undefined; const folderIdXml = isDistinguished ? `` @@ -331,208 +285,17 @@ ${restriction} parentFolderId: string, recursive: boolean = false, ): Promise { - const traversal = recursive ? "Deep" : "Shallow"; - const isDistinguished = - DISTINGUISHED_FOLDERS[parentFolderId.toLowerCase()] !== undefined - || ["msgfolderroot"].includes(parentFolderId.toLowerCase()); - - const folderIdXml = isDistinguished - ? `` - : ``; - - const soap = buildSoapEnvelope(`\ - - - Default - - - ${folderIdXml} - - - `); - - const data = await this.soapRequest( - soap, - "http://schemas.microsoft.com/exchange/services/2006/messages/FindFolder", + return extractFolders( + (body, soapAction) => this.soapRequest(body, soapAction), + parentFolderId, + recursive, ); - - const folders: Folder[] = []; - - for (const msg of this.extractResponseMessages(data)) { - const folderList = msg?.RootFolder?.Folders?.Folder; - if (!folderList) continue; - const list = Array.isArray(folderList) ? folderList : [folderList]; - - for (const f of list) { - folders.push({ - name: f.DisplayName ?? "Unknown", - id: f.FolderId?.["@_Id"] ?? "", - totalCount: f.TotalCount ?? 0, - unreadCount: f.UnreadCount ?? 0, - childFolderCount: f.ChildFolderCount ?? 0, - }); - } - } - - return folders; } - extractEmailSummary(item: any): Email { - const itemType = item["@_xsi_type"] ?? item.__type ?? ""; - const isMeeting = - /MeetingRequest|MeetingResponse|MeetingCancellation|CalendarItem/i.test( - itemType, - ); - - const fromMailbox = item.From?.Mailbox - ?? item.Organizer?.Mailbox - ?? item.Sender?.Mailbox - ?? {}; - - const email: Email = { - subject: item.Subject ?? "(No subject)", - from: fromMailbox.EmailAddress ?? "", - fromName: fromMailbox.Name ?? "", - date: item.DateTimeSent ?? item.DateTimeReceived ?? item.DateTimeCreated - ?? "", - isRead: item.IsRead === "true" || item.IsRead === true, - hasAttachments: item.HasAttachments === "true" - || item.HasAttachments === true, - hasLinks: false, - itemId: item.ItemId?.["@_Id"] ?? "", - size: item.Size ? Number(item.Size) : 0, - isMeeting, - itemType: itemType || "Message", - to: [], - cc: [], - body: "", - bodyType: "Text", - attachments: [], - preview: item.Preview ?? "", - }; - - if (item.DisplayTo) { - email.to = item.DisplayTo.split(";").map((t: string) => t.trim()).filter( - Boolean, - ); - } - if (item.DisplayCc) { - email.cc = item.DisplayCc.split(";").map((c: string) => c.trim()).filter( - Boolean, - ); - } - - if (isMeeting) { - email.location = item.Location ?? ""; - email.start = item.Start ?? item.StartWallClock ?? item.ReminderDueBy - ?? ""; - email.end = item.End ?? item.EndWallClock ?? ""; - } - - return email; + extractEmail(item: any): Email { + return extractEmail(item); } - extractEmailDetails(item: any): Email { - const email = this.extractEmailSummary(item); - - const bodyVal = item.Body?.Value ?? item.Body?.["#text"] ?? ""; - const bodyType = item.Body?.["@_BodyType"] ?? item.Body?.BodyType ?? "Text"; - - if (bodyType === "HTML") { - email.hasLinks = / { - const name = r.Name ?? ""; - const addr = r.EmailAddress ?? ""; - return name && addr ? `${name} <${addr}>` : addr; - }) - .filter(Boolean); - - const ccRecipients = item.CcRecipients?.Mailbox ?? []; - email.cc = (Array.isArray(ccRecipients) ? ccRecipients : [ccRecipients]) - .map((r: any) => { - const name = r.Name ?? ""; - const addr = r.EmailAddress ?? ""; - return name && addr ? `${name} <${addr}>` : addr; - }) - .filter(Boolean); - - const attachments = item.Attachments?.FileAttachment ?? []; - const attList = Array.isArray(attachments) ? attachments : [attachments]; - email.attachments = attList - .filter((a: any) => a) - .map((a: any) => ({ - name: a.Name ?? "", - size: a.Size ? Number(a.Size) : 0, - contentType: a.ContentType ?? "", - attachmentId: a.AttachmentId?.["@_Id"] ?? "", - isInline: a.IsInline === "true" || a.IsInline === true, - })); - - const isMeeting = - /MeetingRequest|MeetingResponse|MeetingCancellation|CalendarItem/i.test( - item["@_xsi_type"] ?? "", - ); - if (isMeeting) { - email.location = item.Location ?? item.EnhancedLocation?.DisplayName - ?? ""; - email.start = item.Start ?? ""; - email.end = item.End ?? ""; - - const reqAtt = item.RequiredAttendees?.Attendee ?? []; - email.requiredAttendees = (Array.isArray(reqAtt) ? reqAtt : [reqAtt]) - .map((a: any) => { - const mb = a.Mailbox ?? {}; - const name = mb.Name ?? ""; - const addr = mb.EmailAddress ?? ""; - return name && addr ? `${name} <${addr}>` : addr; - }) - .filter(Boolean); - - const optAtt = item.OptionalAttendees?.Attendee ?? []; - email.optionalAttendees = (Array.isArray(optAtt) ? optAtt : [optAtt]) - .map((a: any) => { - const mb = a.Mailbox ?? {}; - const name = mb.Name ?? ""; - const addr = mb.EmailAddress ?? ""; - return name && addr ? `${name} <${addr}>` : addr; - }) - .filter(Boolean); - } - - return email; - } - - private htmlToText(html: string): string { - if (!html) return ""; - let text = html.replace(/]*>.*?<\/script>/gis, ""); - text = text.replace(/]*>.*?<\/style>/gis, ""); - text = text.replace(//gi, "\n"); - text = text.replace(/]*>/gi, "\n"); - text = text.replace(/<\/p>/gi, ""); - text = text.replace(/]*>/gi, "\n"); - text = text.replace(/<\/div>/gi, ""); - text = text.replace(/<[^>]+>/g, ""); - text = text.replace(/ /g, " "); - text = text.replace(/&/g, "&"); - text = text.replace(/</g, "<"); - text = text.replace(/>/g, ">"); - text = text.replace(/"/g, "\""); - text = text.replace(/'/g, "'"); - text = text.replace(/\n\s*\n/g, "\n\n"); - text = text.replace(/[ \t]+/g, " "); - return text.trim(); - } - - // ── UpdateItem (for marking read/unread, etc.) ────────────────────────── - async updateItems( itemIds: string[], updates: { fieldUri: string; value: string | boolean }[], @@ -582,8 +345,6 @@ ${changesXml} return this.extractResponseMessages(data); } - // ── GetAttachment ─────────────────────────────────────────────────────── - async getAttachment( attachmentId: string, ): Promise<{ content: Buffer; name: string; contentType: string }> { @@ -617,8 +378,6 @@ ${changesXml} throw new Error(`Attachment '${attachmentId}' not found`); } - // ── FindItem with CalendarView ────────────────────────────────────────── - async findCalendarItems( folderId: string, startDate: string, @@ -650,8 +409,6 @@ ${changesXml} return []; } - // ── GetUserAvailability ───────────────────────────────────────────────── - async getUserAvailability( emails: string[], startDate: string, @@ -699,8 +456,6 @@ ${mailboxDataXml} return data; } - // ── ResolveNames (directory search) ───────────────────────────────────── - async resolveNames( query: string, fullContact: boolean = true, diff --git a/main.ts b/main.ts index 3ac4048..1779f91 100644 --- a/main.ts +++ b/main.ts @@ -4,7 +4,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { Command } from "commander"; import crypto from "node:crypto"; import http from "node:http"; -import { initDataDir } from "./ews_client.ts"; +import { initDataDir } from "./utils/data.ts"; import { registerAuthTools } from "./tools/auth.ts"; import { registerAvailabilityTools } from "./tools/availability.ts"; import { registerCalendarTools } from "./tools/calendar.ts"; diff --git a/tools/auth.ts b/tools/auth.ts index df23bc1..39d3cd4 100644 --- a/tools/auth.ts +++ b/tools/auth.ts @@ -1,28 +1,29 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod/v4"; -import { - clearConfig, - EwsClient, - hasPassword, - loadConfig, - saveConfig, - setPassword, -} from "../ews_client.ts"; -import type { LoginConfig } from "../types/login_config.ts"; +import { clearConfig, hasPassword, loadConfig, saveConfig, setPassword } from "../utils/login.ts"; +import { EwsClient } from "../client/ews_client.ts"; +import { type LoginConfig } from "../utils/login.ts"; export function registerAuthTools(server: McpServer): void { server.registerTool( "login", { - description: "Authenticate to Exchange EWS using NTLM credentials", + description: "Authenticate to Exchange EWS using NTLM credentials." + + " On first use all fields except domain are required." + + " On subsequent logins only password is needed — previously saved" + + " serverUrl, email, username, and domain are reused" + + " automatically from config.", inputSchema: z.object({ - serverUrl: z.string().optional().describe( - "EWS server URL (e.g. https://mail.example.com)", - ), + serverUrl: z.string().optional().describe("EWS server URL"), email: z.string().optional().describe("Email address"), username: z.string().optional().describe("NTLM username"), password: z.string().describe("NTLM password"), - domain: z.string().optional().describe("NTLM domain (default: corp)"), + domain: z.string().optional().describe("NTLM domain"), + }), + outputSchema: z.object({ + success: z.boolean(), + message: z.string().optional(), + error: z.string().optional(), }), }, async ({ serverUrl, email, username, password, domain }) => { @@ -47,8 +48,9 @@ export function registerAuthTools(server: McpServer): void { type: "text" as const, text: JSON.stringify({ success: false, - error: - "Missing required fields. Provide serverUrl, email, and username, or login once with all fields first.", + error: "Missing required fields." + + " Provide serverUrl, email, and username, or" + + " login once with all fields first.", }), }], }; @@ -99,8 +101,15 @@ export function registerAuthTools(server: McpServer): void { server.registerTool( "check_session", { - description: "Check whether the current EWS session is authenticated", + description: + "Check whether the current EWS session is authenticated. No parameters.", inputSchema: z.object({}), + outputSchema: z.object({ + authenticated: z.boolean(), + email: z.string().optional(), + serverUrl: z.string().optional(), + error: z.string().optional(), + }), }, async () => { try { @@ -113,6 +122,10 @@ export function registerAuthTools(server: McpServer): void { error: "Not logged in. Password not found in memory.", }), }], + structuredContent: { + authenticated: false, + error: "Not logged in. Password not found in memory.", + }, }; } const config = loadConfig(); @@ -129,6 +142,12 @@ export function registerAuthTools(server: McpServer): void { error: result.error, }), }], + structuredContent: { + authenticated: result.ok, + email: config.email, + serverUrl: config.serverUrl, + error: result.error, + }, }; } catch (error: any) { return { @@ -139,6 +158,10 @@ export function registerAuthTools(server: McpServer): void { error: error.message ?? String(error), }), }], + structuredContent: { + authenticated: false, + error: error.message ?? String(error), + }, }; } }, @@ -147,8 +170,14 @@ export function registerAuthTools(server: McpServer): void { server.registerTool( "logout", { - description: "Clear stored credentials", + description: + "Clear stored credentials (serverUrl, email, username, domain, password)." + + " No parameters.", inputSchema: z.object({}), + outputSchema: z.object({ + success: z.boolean(), + message: z.string().optional(), + }), }, async () => { clearConfig(); @@ -160,6 +189,10 @@ export function registerAuthTools(server: McpServer): void { message: "Logged out. Credentials cleared.", }), }], + structuredContent: { + success: true, + message: "Logged out. Credentials cleared.", + }, }; }, ); diff --git a/tools/availability.ts b/tools/availability.ts index d412177..ce8570a 100644 --- a/tools/availability.ts +++ b/tools/availability.ts @@ -1,25 +1,38 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod/v4"; -import { EwsClient, loadConfig } from "../ews_client.ts"; +import { loadConfig } from "../utils/login.ts"; +import { EwsClient } from "../client/ews_client.ts"; export function registerAvailabilityTools(server: McpServer): void { server.registerTool( "find_free_time", { - description: "Find free time slots in your own calendar", + description: "Find free time slots in your own calendar." + + " Queries your free/busy status and returns available slots" + + " within working hours that meet the minimum duration.", inputSchema: z.object({ startDate: z.string().describe("Start date in YYYY-MM-DD format"), endDate: z.string().optional().describe( - "End date in YYYY-MM-DD format (defaults to startDate)", + "End date in YYYY-MM-DD format", ), durationMinutes: z.number().default(30).describe( - "Minimum slot duration in minutes (default 30)", + "Minimum slot duration in minutes", ), startHour: z.number().default(9).describe( - "Working day start hour (0-23, default 9)", + "Working day start hour (0-23)", ), endHour: z.number().default(18).describe( - "Working day end hour (0-23, default 18)", + "Working day end hour (0-23)", + ), + }), + outputSchema: z.object({ + freeSlots: z.record( + z.string(), + z.array(z.object({ + start: z.string(), + end: z.string(), + durationMinutes: z.number(), + })), ), }), }, @@ -79,6 +92,7 @@ export function registerAvailabilityTools(server: McpServer): void { type: "text" as const, text: JSON.stringify({ freeSlots }), }], + structuredContent: { freeSlots }, }; } catch (error: any) { return { @@ -94,23 +108,45 @@ export function registerAvailabilityTools(server: McpServer): void { server.registerTool( "find_meeting_time", { - description: "Find meeting times that work for multiple people", + description: "Find common free time for multiple attendees." + + " Queries free/busy for all attendees" + + " and returns slots where everyone is available.", inputSchema: z.object({ emails: z.string().describe( "Comma-separated email addresses of attendees", ), startDate: z.string().describe("Start date in YYYY-MM-DD format"), endDate: z.string().optional().describe( - "End date in YYYY-MM-DD format (defaults to startDate)", + "End date in YYYY-MM-DD format", ), durationMinutes: z.number().default(30).describe( - "Minimum slot duration in minutes (default 30)", + "Minimum slot duration in minutes", ), startHour: z.number().default(9).describe( - "Working day start hour (0-23, default 9)", + "Working day start hour (0-23)", ), endHour: z.number().default(18).describe( - "Working day end hour (0-23, default 18)", + "Working day end hour (0-23)", + ), + }), + outputSchema: z.object({ + period: z.object({ + start: z.string(), + end: z.string(), + }), + attendees: z.array(z.object({ + email: z.string(), + busySlots: z.number().optional(), + freeSlots: z.number().optional(), + calendarEvents: z.number().optional(), + })), + freeSlots: z.record( + z.string(), + z.array(z.object({ + start: z.string(), + end: z.string(), + durationMinutes: z.number(), + })), ), }), }, @@ -192,6 +228,11 @@ export function registerAvailabilityTools(server: McpServer): void { freeSlots: freeByDate, }), }], + structuredContent: { + period: { start: startDate, end: ed }, + attendees: attendeeInfo, + freeSlots: freeByDate, + }, }; } catch (error: any) { return { diff --git a/tools/calendar.ts b/tools/calendar.ts index 6ed2cd6..1c4023d 100644 --- a/tools/calendar.ts +++ b/tools/calendar.ts @@ -2,20 +2,43 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import fs from "node:fs"; import path from "node:path"; import { z } from "zod/v4"; -import { EwsClient, loadConfig } from "../ews_client.ts"; +import { loadConfig } from "../utils/login.ts"; +import { EwsClient } from "../client/ews_client.ts"; export function registerCalendarTools(server: McpServer): void { server.registerTool( "get_calendar_events", { - description: "Get calendar events within a date range", + description: "Get calendar events within a date range." + + " Set includeBody=true to fetch organizer, attendees, and body via GetItem.", inputSchema: z.object({ startDate: z.string().describe("Start date in YYYY-MM-DD format"), endDate: z.string().describe("End date in YYYY-MM-DD format"), includeBody: z.boolean().default(false).describe( - "If True, fetch full event details (organizer, attendees, body) via GetItem", + "If true, fetch full event details (organizer, attendees, body)" + + " via GetItem", ), }), + outputSchema: z.object({ + events: z.array(z.object({ + subject: z.string(), + start: z.string(), + end: z.string(), + location: z.string(), + isAllDay: z.boolean(), + isCancelled: z.boolean(), + isMeeting: z.boolean(), + isRecurring: z.boolean(), + organizer: z.string(), + organizerEmail: z.string(), + myResponse: z.string(), + itemId: z.string(), + body: z.string(), + requiredAttendees: z.array(z.string()), + optionalAttendees: z.array(z.string()), + })), + count: z.number(), + }), }, async ({ startDate, endDate, includeBody }) => { try { @@ -51,7 +74,7 @@ export function registerCalendarTools(server: McpServer): void { }; if (includeBody && event.itemId) { - const details = client.extractEmailDetails(item); + const details = client.extractEmail(item); event.body = details.body; event.requiredAttendees = details.requiredAttendees ?? []; event.optionalAttendees = details.optionalAttendees ?? []; @@ -68,6 +91,7 @@ export function registerCalendarTools(server: McpServer): void { type: "text" as const, text: JSON.stringify({ events, count: events.length }), }], + structuredContent: { events, count: events.length }, }; } catch (error: any) { return { @@ -84,21 +108,38 @@ export function registerCalendarTools(server: McpServer): void { "download_event_attachments", { description: - "Download all file attachments from a calendar event to disk", + "Download all file attachments from a calendar event to disk." + + " Inline (embedded) attachments are skipped —" + + " only standalone file attachments are downloaded.", inputSchema: z.object({ itemId: z.string().describe( "The Exchange ItemId of the calendar event", ), targetFolder: z.string().default("/tmp/attachments").describe( - "Local directory to save files (default /tmp/attachments)", + "Local directory to save files", ), }), + outputSchema: z.object({ + success: z.boolean(), + downloaded: z.array(z.object({ + name: z.string(), + path: z.string(), + size: z.number(), + contentType: z.string(), + })), + count: z.number(), + errors: z.array(z.object({ + name: z.string(), + error: z.string(), + })).optional(), + message: z.string().optional(), + }), }, async ({ itemId, targetFolder }) => { try { const client = new EwsClient(loadConfig()); const item = await client.getItem(itemId); - const email = client.extractEmailDetails(item); + const email = client.extractEmail(item); const attachments = email.attachments ?? []; const fileAttachments = attachments.filter( @@ -116,6 +157,12 @@ export function registerCalendarTools(server: McpServer): void { message: "No downloadable file attachments.", }), }], + structuredContent: { + success: true, + downloaded: [], + count: 0, + message: "No downloadable file attachments.", + }, }; } @@ -173,6 +220,9 @@ export function registerCalendarTools(server: McpServer): void { ...(errors.length ? { errors } : {}), }), }], + structuredContent: errors.length === 0 + ? { success: true, downloaded, count: downloaded.length } + : { success: false, downloaded, count: downloaded.length, errors }, }; } catch (error: any) { return { diff --git a/tools/email.ts b/tools/email.ts index c5c6825..657833d 100644 --- a/tools/email.ts +++ b/tools/email.ts @@ -2,7 +2,8 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import fs from "node:fs"; import path from "node:path"; import { z } from "zod/v4"; -import { EwsClient, loadConfig } from "../ews_client.ts"; +import { loadConfig } from "../utils/login.ts"; +import { EwsClient } from "../client/ews_client.ts"; function getClient(): EwsClient { return new EwsClient(loadConfig()); @@ -12,31 +13,41 @@ export function registerEmailTools(server: McpServer): void { server.registerTool( "get_emails", { - description: "Get emails from a mailbox folder", + description: "Get emails from a mailbox folder." + + " Supports optional text search via query/queryScope.", inputSchema: z.object({ folder: z.string().default("Inbox").describe( - "Folder name (Inbox, Sent, Drafts, Deleted, Junk, or custom)", + "Folder name (Inbox, Sent, Drafts, Deleted, Junk, or custom)." + + " Supports Russian folder names", ), limit: z.number().default(10).describe( - "Maximum number of emails to return (default 10, max 50)", + "Maximum number of emails to return", ), offset: z.number().default(0).describe( "Number of emails to skip for pagination", ), includeBody: z.boolean().default(false).describe( - "If True, fetch full body for each email (slower)", + "If true, fetches full email body for each email (slower)", ), unreadOnly: z.boolean().default(false).describe( - "If True, only return unread emails", - ), - idsOnly: z.boolean().default(false).describe( - "If True, return only item IDs and dates (max limit 500)", + "If true, only return unread emails", ), 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)"), + .describe("Search scope: all, subject, body, or from"), + }), + outputSchema: z.object({ + emails: z.array(z.object({ + itemId: z.string(), + subject: z.string(), + from: z.string(), + date: z.string(), + isRead: z.boolean(), + hasAttachments: z.boolean(), + })), + count: z.number(), }), }, async ( @@ -46,19 +57,18 @@ export function registerEmailTools(server: McpServer): void { offset, includeBody, unreadOnly, - idsOnly, query, queryScope, }, ) => { try { const client = getClient(); - const maxLimit = idsOnly ? 500 : 50; + const maxLimit = 50; if (limit > maxLimit) limit = maxLimit; offset = Math.max(0, offset); const folderId = await client.getFolderId(folder); - const baseShape = idsOnly ? "IdOnly" : "AllProperties"; + const baseShape = "AllProperties"; if (query && !query.trim()) query = undefined; @@ -109,16 +119,16 @@ export function registerEmailTools(server: McpServer): void { let restriction = ""; if (restrictions.length === 1) { restriction = `\ - - ${restrictions[0]} - `; + + ${restrictions[0]} + `; } else if (restrictions.length > 1) { restriction = `\ - - - ${restrictions.join("\n")} - - `; + + + ${restrictions.join("\n")} + + `; } const items = await client.findItems(folderId, { @@ -128,33 +138,9 @@ export function registerEmailTools(server: McpServer): void { restriction, }); - if (idsOnly) { - const result = items.map((item: any) => ({ - itemId: item.ItemId?.["@_Id"] ?? "", - date: item.DateTimeReceived ?? "", - subject: item.Subject ?? "", - })); - return { - content: [{ - type: "text" as const, - text: JSON.stringify({ itemIds: result, count: result.length }), - }], - }; - } - const emails = []; for (const item of items) { - const email = client.extractEmailSummary(item); - - if (includeBody && email.itemId) { - const details = client.extractEmailDetails(item); - email.to = details.to; - email.cc = details.cc; - email.body = details.body; - email.hasLinks = details.hasLinks; - } - - emails.push(email); + emails.push(client.extractEmail(item)); } return { @@ -162,6 +148,7 @@ export function registerEmailTools(server: McpServer): void { type: "text" as const, text: JSON.stringify({ emails, count: emails.length }), }], + structuredContent: { emails, count: emails.length }, }; } catch (error: any) { return { @@ -177,21 +164,43 @@ export function registerEmailTools(server: McpServer): void { server.registerTool( "get_email", { - description: "Get a single email with full body and details", + description: + "Get a single email with full body and details by Exchange ItemId.", inputSchema: z.object({ itemId: z.string().describe( "The Exchange ItemId of the email to retrieve", ), }), + outputSchema: z.object({ + itemId: z.string(), + subject: z.string(), + from: z.string(), + fromName: z.string().optional(), + to: z.array(z.string()).optional(), + cc: z.array(z.string()).optional(), + date: z.string(), + body: z.string().optional(), + isRead: z.boolean().optional(), + hasAttachments: z.boolean().optional(), + hasLinks: z.boolean().optional(), + attachments: z.array(z.object({ + name: z.string(), + size: z.number(), + contentType: z.string(), + attachmentId: z.string().optional(), + isInline: z.boolean().optional(), + })).optional(), + }), }, async ({ itemId }) => { try { const client = getClient(); const item = await client.getItem(itemId); - const email = client.extractEmailDetails(item); + const email = client.extractEmail(item); return { content: [{ type: "text" as const, text: JSON.stringify(email) }], + structuredContent: { emails: [email] }, }; } catch (error: any) { return { @@ -207,15 +216,21 @@ export function registerEmailTools(server: McpServer): void { server.registerTool( "mark_email_read", { - description: "Mark one or more emails as read or unread", + description: + "Mark one or more emails as read or unread by their Exchange ItemIds.", inputSchema: z.object({ itemIds: z.array(z.string()).describe( "List of Exchange ItemIds to update", ), isRead: z.boolean().default(true).describe( - "True to mark as read, False to mark as unread", + "True to mark as read, false to mark as unread", ), }), + outputSchema: z.object({ + success: z.boolean(), + message: z.string().optional(), + error: z.string().optional(), + }), }, async ({ itemIds, isRead }) => { try { @@ -234,6 +249,10 @@ export function registerEmailTools(server: McpServer): void { type: "text" as const, text: JSON.stringify({ error: errors.join("; ") }), }], + structuredContent: { + success: false, + error: errors.join("; "), + }, }; } @@ -246,6 +265,10 @@ export function registerEmailTools(server: McpServer): void { message: `Marked ${itemIds.length} email(s) as ${status}.`, }), }], + structuredContent: { + success: true, + message: `Marked ${itemIds.length} email(s) as ${status}.`, + }, }; } catch (error: any) { return { @@ -253,6 +276,10 @@ export function registerEmailTools(server: McpServer): void { type: "text" as const, text: JSON.stringify({ error: error.message ?? String(error) }), }], + structuredContent: { + success: false, + error: error.message ?? String(error), + }, }; } }, @@ -261,19 +288,36 @@ export function registerEmailTools(server: McpServer): void { server.registerTool( "download_attachments", { - description: "Download all file attachments from an email to disk", + description: "Download all file attachments from an email to disk." + + " Inline (embedded) attachments are skipped —" + + " only standalone file attachments are downloaded.", inputSchema: z.object({ itemId: z.string().describe("The Exchange ItemId of the email"), targetFolder: z.string().default("/tmp/attachments").describe( - "Local directory to save files (default /tmp/attachments)", + "Local directory to save files", ), }), + outputSchema: z.object({ + success: z.boolean(), + downloaded: z.array(z.object({ + name: z.string(), + path: z.string(), + size: z.number(), + contentType: z.string(), + })), + count: z.number(), + errors: z.array(z.object({ + name: z.string(), + error: z.string(), + })).optional(), + message: z.string().optional(), + }), }, async ({ itemId, targetFolder }) => { try { const client = new EwsClient(loadConfig()); const item = await client.getItem(itemId); - const email = client.extractEmailDetails(item); + const email = client.extractEmail(item); const attachments = email.attachments ?? []; const fileAttachments = attachments.filter( @@ -291,6 +335,12 @@ export function registerEmailTools(server: McpServer): void { message: "No downloadable file attachments.", }), }], + structuredContent: { + success: true, + downloaded: [], + count: 0, + message: "No downloadable file attachments.", + }, }; } @@ -348,6 +398,9 @@ export function registerEmailTools(server: McpServer): void { ...(errors.length ? { errors } : {}), }), }], + structuredContent: errors.length === 0 + ? { success: true, downloaded, count: downloaded.length } + : { success: false, downloaded, count: downloaded.length, errors }, }; } catch (error: any) { return { diff --git a/tools/folders.ts b/tools/folders.ts index 1615a65..7acdc64 100644 --- a/tools/folders.ts +++ b/tools/folders.ts @@ -1,20 +1,31 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod/v4"; -import { EwsClient, loadConfig } from "../ews_client.ts"; +import { loadConfig } from "../utils/login.ts"; +import { EwsClient } from "../client/ews_client.ts"; export function registerFolderTools(server: McpServer): void { server.registerTool( "get_folders", { - description: "List mail folders from the Exchange mailbox", + description: "List mailbox folders from the Exchange mailbox.", inputSchema: z.object({ parentFolderId: z.string().default("msgfolderroot").describe( - "Parent folder to list children of (default: msgfolderroot)", + "Parent folder to list children of." + + " Supports distinguished names (e.g. inbox, calendar)", ), recursive: z.boolean().default(false).describe( - "If True, traverse all subfolders recursively", + "If true, recursively traverse all subfolders", ), }), + outputSchema: z.object({ + folders: z.array(z.object({ + name: z.string(), + id: z.string(), + totalCount: z.number(), + unreadCount: z.number(), + childFolderCount: z.number(), + })), + }), }, async ({ parentFolderId, recursive }) => { try { @@ -23,6 +34,7 @@ export function registerFolderTools(server: McpServer): void { return { content: [{ type: "text" as const, text: JSON.stringify(folders) }], + structuredContent: { folders }, }; } catch (error: any) { return { diff --git a/tools/people.ts b/tools/people.ts index b3c411f..db9635b 100644 --- a/tools/people.ts +++ b/tools/people.ts @@ -1,22 +1,45 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod/v4"; -import { EwsClient, loadConfig } from "../ews_client.ts"; +import { loadConfig } from "../utils/login.ts"; +import { EwsClient } from "../client/ews_client.ts"; export function registerPeopleTools(server: McpServer): void { server.registerTool( "find_person", { - description: "Search for people in the corporate directory", + description: + "Search for people in the corporate directory (Active Directory)" + + " by name, email, or keyword.", inputSchema: z.object({ query: z.string().describe( "Name, email address, or keyword to search for", ), }), + outputSchema: z.object({ + name: z.string(), + email: z.string(), + mailboxType: z.string(), + firstName: z.string(), + lastName: z.string(), + jobTitle: z.string(), + department: z.string(), + company: z.string(), + office: z.string(), + alias: z.string(), + manager: z.string(), + managerEmail: z.string(), + phones: z.record(z.string(), z.string()), + address: z.string(), + directReports: z.array(z.object({ + name: z.string(), + email: z.string(), + })), + }), }, async ({ query }) => { try { const client = new EwsClient(loadConfig()); - const resolutions = await client.resolveNames(query, true); + const resolutions = await client.resolveNames(query); const people = resolutions.map((r: any) => { const mailbox = r.Mailbox ?? {}; @@ -84,6 +107,7 @@ export function registerPeopleTools(server: McpServer): void { return { content: [{ type: "text" as const, text: JSON.stringify(people) }], + structuredContent: { people }, }; } catch (error: any) { return { diff --git a/types/calendar_event.ts b/types/calendar_event.ts deleted file mode 100644 index 1e65734..0000000 --- a/types/calendar_event.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface CalendarEvent { - subject: string; - start: string; - end: string; - location: string; - isAllDay: boolean; - isCancelled: boolean; - isMeeting: boolean; - isRecurring: boolean; - organizer: string; - organizerEmail: string; - myResponse: string; - itemId: string; - body: string; - requiredAttendees: string[]; - optionalAttendees: string[]; -} diff --git a/types/email.ts b/types/email.ts deleted file mode 100644 index 1ac1b24..0000000 --- a/types/email.ts +++ /dev/null @@ -1,32 +0,0 @@ -export interface Email { - subject: string; - from: string; - fromName: string; - date: string; - isRead: boolean; - hasAttachments: boolean; - hasLinks: boolean; - itemId: string; - size: number; - isMeeting: boolean; - itemType: string; - to: string[]; - cc: string[]; - body: string; - bodyType: string; - attachments: Attachment[]; - preview: string; - location?: string; - start?: string; - end?: string; - requiredAttendees?: string[]; - optionalAttendees?: string[]; -} - -export interface Attachment { - name: string; - size: number; - contentType: string; - attachmentId: string; - isInline: boolean; -} diff --git a/types/folder.ts b/types/folder.ts deleted file mode 100644 index 6bef6e0..0000000 --- a/types/folder.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface Folder { - name: string; - id: string; - totalCount: number; - unreadCount: number; - childFolderCount: number; -} diff --git a/types/free_slot.ts b/types/free_slot.ts deleted file mode 100644 index cae3873..0000000 --- a/types/free_slot.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface FreeSlot { - date: string; - start: string; - end: string; - durationMinutes: number; -} diff --git a/types/login_config.ts b/types/login_config.ts deleted file mode 100644 index ef2c777..0000000 --- a/types/login_config.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface LoginConfig { - serverUrl: string; - email: string; - username: string; - domain?: string; -} diff --git a/types/meeting_result.ts b/types/meeting_result.ts deleted file mode 100644 index fc8cbf7..0000000 --- a/types/meeting_result.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface MeetingResult { - success: boolean; - subject: string; - date: string; - startTime: string; - endTime: string; - location: string; - requiredAttendees: string[]; - optionalAttendees: string[]; - error: string; -} diff --git a/types/person.ts b/types/person.ts deleted file mode 100644 index 1916cc3..0000000 --- a/types/person.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface Person { - name: string; - email: string; - mailboxType: string; - firstName: string; - lastName: string; - jobTitle: string; - department: string; - company: string; - office: string; - alias: string; - manager: string; - managerEmail: string; - phones: Record; - address: string; - directReports: Array<{ name: string; email: string }>; -} diff --git a/utils/data.ts b/utils/data.ts new file mode 100644 index 0000000..98bc022 --- /dev/null +++ b/utils/data.ts @@ -0,0 +1,13 @@ +import fs from "node:fs"; +import path from "node:path"; + +let dataDir: string = "./data"; + +export function initDataDir(dir: string): void { + dataDir = path.resolve(dir); + fs.mkdirSync(dataDir, { recursive: true }); +} + +export function configFilePath(): string { + return path.join(dataDir, "config.json"); +} diff --git a/utils/extract_email.ts b/utils/extract_email.ts new file mode 100644 index 0000000..45572f3 --- /dev/null +++ b/utils/extract_email.ts @@ -0,0 +1,156 @@ +import { htmlToText } from "./html_to_text.ts"; + +export interface Attachment { + name: string; + size: number; + contentType: string; + attachmentId: string; + isInline: boolean; +} + +export interface Email { + subject: string; + from: string; + fromName: string; + date: string; + isRead: boolean; + hasAttachments: boolean; + hasLinks: boolean; + itemId: string; + size: number; + isMeeting: boolean; + itemType: string; + to: string[]; + cc: string[]; + body: string; + bodyType: string; + attachments: Attachment[]; + preview: string; + location?: string; + start?: string; + end?: string; + requiredAttendees?: string[]; + optionalAttendees?: string[]; +} + +export function extractEmail(item: any): Email { + const itemType = item["@_xsi_type"] ?? item.__type ?? ""; + const isMeeting = + /MeetingRequest|MeetingResponse|MeetingCancellation|CalendarItem/i.test( + itemType, + ); + + const fromMailbox = item.From?.Mailbox + ?? item.Organizer?.Mailbox + ?? item.Sender?.Mailbox + ?? {}; + + const email: Email = { + subject: item.Subject ?? "(No subject)", + from: fromMailbox.EmailAddress ?? "", + fromName: fromMailbox.Name ?? "", + date: item.DateTimeSent ?? item.DateTimeReceived ?? item.DateTimeCreated + ?? "", + isRead: item.IsRead === "true" || item.IsRead === true, + hasAttachments: item.HasAttachments === "true" + || item.HasAttachments === true, + hasLinks: false, + itemId: item.ItemId?.["@_Id"] ?? "", + size: item.Size ? Number(item.Size) : 0, + isMeeting, + itemType: itemType || "Message", + to: [], + cc: [], + body: "", + bodyType: "Text", + attachments: [], + preview: item.Preview ?? "", + }; + + if (item.DisplayTo) { + email.to = item.DisplayTo.split(";").map((t: string) => t.trim()).filter( + Boolean, + ); + } + if (item.DisplayCc) { + email.cc = item.DisplayCc.split(";").map((c: string) => c.trim()).filter( + Boolean, + ); + } + + if (isMeeting) { + email.location = item.Location ?? ""; + email.start = item.Start ?? item.StartWallClock ?? item.ReminderDueBy + ?? ""; + email.end = item.End ?? item.EndWallClock ?? ""; + } + + const bodyVal = item.Body?.Value ?? item.Body?.["#text"] ?? ""; + const bodyType = item.Body?.["@_BodyType"] ?? item.Body?.BodyType ?? "Text"; + + if (bodyType === "HTML") { + email.hasLinks = / { + const name = r.Name ?? ""; + const addr = r.EmailAddress ?? ""; + return name && addr ? `${name} <${addr}>` : addr; + }) + .filter(Boolean); + + const ccRecipients = item.CcRecipients?.Mailbox ?? []; + email.cc = (Array.isArray(ccRecipients) ? ccRecipients : [ccRecipients]) + .map((r: any) => { + const name = r.Name ?? ""; + const addr = r.EmailAddress ?? ""; + return name && addr ? `${name} <${addr}>` : addr; + }) + .filter(Boolean); + + const attachments = item.Attachments?.FileAttachment ?? []; + const attList = Array.isArray(attachments) ? attachments : [attachments]; + email.attachments = attList + .filter((a: any) => a) + .map((a: any) => ({ + name: a.Name ?? "", + size: a.Size ? Number(a.Size) : 0, + contentType: a.ContentType ?? "", + attachmentId: a.AttachmentId?.["@_Id"] ?? "", + isInline: a.IsInline === "true" || a.IsInline === true, + })); + + if (isMeeting) { + email.location = item.Location ?? item.EnhancedLocation?.DisplayName ?? ""; + email.start = item.Start ?? ""; + email.end = item.End ?? ""; + + const reqAtt = item.RequiredAttendees?.Attendee ?? []; + email.requiredAttendees = (Array.isArray(reqAtt) ? reqAtt : [reqAtt]) + .map((a: any) => { + const mb = a.Mailbox ?? {}; + const name = mb.Name ?? ""; + const addr = mb.EmailAddress ?? ""; + return name && addr ? `${name} <${addr}>` : addr; + }) + .filter(Boolean); + + const optAtt = item.OptionalAttendees?.Attendee ?? []; + email.optionalAttendees = (Array.isArray(optAtt) ? optAtt : [optAtt]) + .map((a: any) => { + const mb = a.Mailbox ?? {}; + const name = mb.Name ?? ""; + const addr = mb.EmailAddress ?? ""; + return name && addr ? `${name} <${addr}>` : addr; + }) + .filter(Boolean); + } + + return email; +} diff --git a/utils/extract_folder.ts b/utils/extract_folder.ts new file mode 100644 index 0000000..464e428 --- /dev/null +++ b/utils/extract_folder.ts @@ -0,0 +1,101 @@ +export interface Folder { + name: string; + id: string; + totalCount: number; + unreadCount: number; + childFolderCount: number; +} + +type SoapRequest = (body: string, soapAction: string) => Promise; + +export async function extractFolders( + soapRequest: SoapRequest, + parentFolderId: string, + recursive: boolean = false, +): Promise { + const DISTINGUISHED_FOLDERS: Record = { + inbox: "inbox", + "входящие": "inbox", + sent: "sentitems", + "отправленные": "sentitems", + drafts: "drafts", + "черновики": "drafts", + deleted: "deleteditems", + "удаленные": "deleteditems", + junk: "junkemail", + "нежелательная почта": "junkemail", + outbox: "outbox", + "исходящие": "outbox", + calendar: "calendar", + "календарь": "calendar", + }; + + function buildSoapEnvelope(body: string): string { + return ` + + +${body} + +`; + } + + function extractResponseMessages(data: any): any[] { + const body = data?.Envelope?.Body; + if (!body) return []; + const firstKey = Object.keys(body).find((k) => k.endsWith("Response")); + if (!firstKey) return []; + const rm = body[firstKey]?.ResponseMessages; + if (!rm) return []; + const msgKey = Object.keys(rm).find((k) => k.endsWith("ResponseMessage")); + if (!msgKey) return []; + const msgs = rm[msgKey]; + return Array.isArray(msgs) ? msgs : [msgs]; + } + + const traversal = recursive ? "Deep" : "Shallow"; + const isDistinguished = + DISTINGUISHED_FOLDERS[parentFolderId.toLowerCase()] !== undefined + || ["msgfolderroot"].includes(parentFolderId.toLowerCase()); + + const folderIdXml = isDistinguished + ? `` + : ``; + + const soap = buildSoapEnvelope(`\ + + + Default + + + ${folderIdXml} + + + `); + + const data = await soapRequest( + soap, + "http://schemas.microsoft.com/exchange/services/2006/messages/FindFolder", + ); + + const folders: Folder[] = []; + + for (const msg of extractResponseMessages(data)) { + const folderList = msg?.RootFolder?.Folders?.Folder; + if (!folderList) continue; + const list = Array.isArray(folderList) ? folderList : [folderList]; + + for (const f of list) { + folders.push({ + name: f.DisplayName ?? "Unknown", + id: f.FolderId?.["@_Id"] ?? "", + totalCount: f.TotalCount ?? 0, + unreadCount: f.UnreadCount ?? 0, + childFolderCount: f.ChildFolderCount ?? 0, + }); + } + } + + return folders; +} diff --git a/utils/html_to_text.ts b/utils/html_to_text.ts new file mode 100644 index 0000000..579520b --- /dev/null +++ b/utils/html_to_text.ts @@ -0,0 +1,20 @@ +export function htmlToText(html: string): string { + if (!html) return ""; + let text = html.replace(/]*>.*?<\/script>/gis, ""); + text = text.replace(/]*>.*?<\/style>/gis, ""); + text = text.replace(//gi, "\n"); + text = text.replace(/]*>/gi, "\n"); + text = text.replace(/<\/p>/gi, ""); + text = text.replace(/]*>/gi, "\n"); + text = text.replace(/<\/div>/gi, ""); + text = text.replace(/<[^>]+>/g, ""); + text = text.replace(/ /g, " "); + text = text.replace(/&/g, "&"); + text = text.replace(/</g, "<"); + text = text.replace(/>/g, ">"); + text = text.replace(/"/g, "\""); + text = text.replace(/'/g, "'"); + text = text.replace(/\n\s*\n/g, "\n\n"); + text = text.replace(/[ \t]+/g, " "); + return text.trim(); +} diff --git a/utils/login.ts b/utils/login.ts new file mode 100644 index 0000000..2a9ea45 --- /dev/null +++ b/utils/login.ts @@ -0,0 +1,51 @@ +import fs from "node:fs"; +import { configFilePath, initDataDir } from "./data.ts"; + +// Re-export for backward compatibility +export { initDataDir }; + +export interface LoginConfig { + serverUrl: string; + email: string; + username: string; + domain?: string; +} + +let inMemoryPassword: string | null = null; + +export function setPassword(password: string): void { + inMemoryPassword = password; +} + +export function getPassword(): string | null { + return inMemoryPassword; +} + +export function hasPassword(): boolean { + return inMemoryPassword !== null; +} + +export function clearPassword(): void { + inMemoryPassword = null; +} + +export function loadConfig(): LoginConfig { + const filePath = configFilePath(); + if (!fs.existsSync(filePath)) { + throw new Error("Not logged in. Use the login tool first."); + } + return JSON.parse(fs.readFileSync(filePath, "utf-8")) as LoginConfig; +} + +export function saveConfig(config: LoginConfig): void { + const filePath = configFilePath(); + fs.writeFileSync(filePath, JSON.stringify(config, null, 2), "utf-8"); +} + +export function clearConfig(): void { + const filePath = configFilePath(); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + clearPassword(); +}