import { XMLParser } from "fast-xml-parser"; import https from "node:https"; import { createRequire } from "node:module"; import { promisify } from "node:util"; import { extractEmail, type Email } from "../utils/extract_email.ts"; import { extractFolders, type Folder } from "../utils/extract_folder.ts"; import { loadConfig, saveConfig, clearConfig, setPassword, getPassword, hasPassword, initDataDir, type LoginConfig, } from "../utils/config.ts"; const ntlm: any = createRequire(import.meta.url)("httpntlm"); const postAsync = promisify(ntlm.post.bind(ntlm)); const SSL_OP_LEGACY_SERVER_CONNECT = 0x00000004; const AGENT = new https.Agent({ keepAlive: true, rejectUnauthorized: false, secureOptions: SSL_OP_LEGACY_SERVER_CONNECT, }); 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} `; } const PARSER = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", removeNSPrefix: true, textNodeName: "#text", }); export class EwsClient { private config: LoginConfig; private ewsUrl: string; constructor(config: LoginConfig, password?: string) { this.config = config; this.ewsUrl = `${config.serverUrl.replace(/\/+$/, "")}/EWS/Exchange.asmx`; if (password) setPassword(password); } private get domain(): string { return this.config.domain ?? "corp"; } private get password(): string { const pw = getPassword(); if (!pw) throw new Error("Not logged in. Password not found in memory."); return pw; } private async soapRequest(body: string, soapAction: string): Promise { const res = await postAsync({ url: this.ewsUrl, username: this.config.username, domain: this.domain, password: this.password, agent: AGENT, headers: { "Content-Type": "text/xml; charset=utf-8", SOAPAction: soapAction, }, body, }); if (typeof res.body !== "string") { throw new Error(`NTLM request failed, status=${res.statusCode}`); } return PARSER.parse(res.body); } private 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]; } async verifyConnection(): Promise<{ ok: boolean; error?: string }> { try { await this.getFolderId("inbox"); return { ok: true }; } catch (error: any) { return { ok: false, error: error.message ?? String(error) }; } } async getFolderId(folderName: string): Promise { const lower = folderName.toLowerCase(); const distinguished = (DISTINGUISHED_FOLDERS as Record)[lower]; if (distinguished) { const soap = buildSoapEnvelope(`\ IdOnly `); const data = await this.soapRequest( soap, "http://schemas.microsoft.com/exchange/services/2006/messages/GetFolder", ); for (const msg of this.extractResponseMessages(data)) { const folder = msg?.Folders?.Folder; if (folder?.FolderId?.["@_Id"]) { return folder.FolderId["@_Id"]; } } } const soap = buildSoapEnvelope(`\ Default `); const data = await this.soapRequest( soap, "http://schemas.microsoft.com/exchange/services/2006/messages/FindFolder", ); for (const msg of this.extractResponseMessages(data)) { const folders = msg?.RootFolder?.Folders?.Folder; if (!folders) continue; const list = Array.isArray(folders) ? folders : [folders]; for (const f of list) { if (f.DisplayName?.toLowerCase() === lower) { return f.FolderId?.["@_Id"] ?? ""; } } } throw new Error(`Folder '${folderName}' not found`); } async findItems( folderId: string, options: { limit?: number; offset?: number; baseShape?: string; restriction?: string; traversal?: string; } = {}, ): Promise { const { limit = 10, offset = 0, baseShape = "AllProperties", restriction = "", traversal = "Shallow", } = options; const isDistinguished = (DISTINGUISHED_FOLDERS as Record)[folderId.toLowerCase()] !== undefined; const folderIdXml = isDistinguished ? `` : ``; const soap = buildSoapEnvelope(`\ ${baseShape} ${folderIdXml} ${restriction} `); const data = await this.soapRequest( soap, "http://schemas.microsoft.com/exchange/services/2006/messages/FindItem", ); for (const msg of this.extractResponseMessages(data)) { const items = msg?.RootFolder?.Items?.Message ?? msg?.RootFolder?.Items?.CalendarItem; if (!items) continue; return Array.isArray(items) ? items : [items]; } return []; } async getItem(itemId: string): Promise { const soap = buildSoapEnvelope(`\ AllProperties HTML `); const data = await this.soapRequest( soap, "http://schemas.microsoft.com/exchange/services/2006/messages/GetItem", ); for (const msg of this.extractResponseMessages(data)) { const items = msg?.Items; if (!items) continue; const itemKey = Object.keys(items).find((k) => [ "Message", "CalendarItem", "MeetingRequest", "MeetingResponse", "MeetingCancellation", ].includes(k) ); if (itemKey) { return items[itemKey]; } } throw new Error(`Item '${itemId}' not found`); } async findFolders( parentFolderId: string, recursive: boolean = false, ): Promise { return extractFolders( (body, soapAction) => this.soapRequest(body, soapAction), parentFolderId, recursive, ); } extractEmail(item: any): Email { return extractEmail(item); } async updateItems( itemIds: string[], updates: { fieldUri: string; value: string | boolean }[], ): Promise { const propName = (fieldUri: string): string => { const parts = fieldUri.split(":"); return parts[parts.length - 1]; }; const changesXml = itemIds.map((id) => { const updatesXml = updates.map((u) => { const val = typeof u.value === "boolean" ? (u.value ? "true" : "false") : u.value; return `\ ${String(val)} `; }).join("\n"); return `\ ${updatesXml} `; }).join("\n"); const soap = buildSoapEnvelope(`\ ${changesXml} `); const data = await this.soapRequest( soap, "http://schemas.microsoft.com/exchange/services/2006/messages/UpdateItem", ); return this.extractResponseMessages(data); } async getAttachment( attachmentId: string, ): Promise<{ content: Buffer; name: string; contentType: string }> { const soap = buildSoapEnvelope(`\ `); const data = await this.soapRequest( soap, "http://schemas.microsoft.com/exchange/services/2006/messages/GetAttachment", ); for (const msg of this.extractResponseMessages(data)) { const attachments = msg?.Attachments?.FileAttachment; if (!attachments) continue; const list = Array.isArray(attachments) ? attachments : [attachments]; for (const a of list) { const contentBase64 = a.Content ?? ""; const content = Buffer.from(contentBase64, "base64"); return { content, name: a.Name ?? "attachment", contentType: a.ContentType ?? "application/octet-stream", }; } } throw new Error(`Attachment '${attachmentId}' not found`); } async findCalendarItems( folderId: string, startDate: string, endDate: string, ): Promise { const soap = buildSoapEnvelope(`\ AllProperties HTML `); const data = await this.soapRequest( soap, "http://schemas.microsoft.com/exchange/services/2006/messages/FindItem", ); for (const msg of this.extractResponseMessages(data)) { const items = msg?.RootFolder?.Items?.CalendarItem; if (!items) continue; return Array.isArray(items) ? items : [items]; } return []; } async getUserAvailability( emails: string[], startDate: string, endDate: string, requestedView: string = "DetailedMerged", ): Promise { const mailboxDataXml = emails.map((email) => `\ ${ email.replace(/&/g, "&").replace(/ Required ` ).join("\n"); const soap = ` ${mailboxDataXml} ${startDate}T00:00:00 ${endDate}T23:59:59 30 ${requestedView} `; const data = await this.soapRequest( soap, "http://schemas.microsoft.com/exchange/services/2006/messages/GetUserAvailability", ); return data; } async resolveNames( query: string, fullContact: boolean = true, ): Promise { const soap = buildSoapEnvelope(`\ ${ query.replace(/&/g, "&").replace(/ `); const data = await this.soapRequest( soap, "http://schemas.microsoft.com/exchange/services/2006/messages/ResolveNames", ); for (const msg of this.extractResponseMessages(data)) { const resolutions = msg?.ResolutionSet?.Resolution; if (resolutions) { return Array.isArray(resolutions) ? resolutions : [resolutions]; } } return []; } } // Re-export for backward compatibility export { initDataDir, loadConfig, saveConfig, clearConfig, setPassword, getPassword, hasPassword, } from "../utils/config.ts";