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; } 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 PARSER = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", removeNSPrefix: true, textNodeName: "#text", }); 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 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(); } 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}`); } const parsed = PARSER.parse(res.body); return parsed; } 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[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 soap = buildSoapEnvelope(`\ ${baseShape} ${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 { 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", ); 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; } 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(); } }