import { Injectable, Logger } from '@nestjs/common'; // eslint-disable-next-line @typescript-eslint/no-require-imports const httpntlm = require('httpntlm') as { post: (opts: any, cb: (err: Error | null, res: any) => void) => void }; import type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface'; import type { InboxProvider } from './inbox-provider.interface'; const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB — T-07-05 // ─── EWS SOAP namespace constants ──────────────────────────────────────────── const NS_SOAP = 'http://schemas.xmlsoap.org/soap/envelope/'; const NS_TYPES = 'http://schemas.microsoft.com/exchange/services/2006/types'; const NS_MESSAGES = 'http://schemas.microsoft.com/exchange/services/2006/messages'; // ─── SOAP envelope builders ─────────────────────────────────────────────────── function soapEnvelope(body: string): string { return ` ${body} `; } /** folderElement: either or */ function findItemSoap(folderElement: string, maxResults: number, senderFilter?: string): string { const isReadFilter = ` `; const restriction = senderFilter ? ` ${isReadFilter} ` : `${isReadFilter}`; return soapEnvelope(` IdOnly ${restriction} ${folderElement} `); } function getItemSoap(itemIds: string[]): string { const ids = itemIds.map((id) => ``).join(''); return soapEnvelope(` IdOnly ${ids} `); } /** * GetItem SOAP requesting item:Body instead of item:Attachments — used by * fetchMessages (D-02) to retrieve the HTML/text body without touching the * attachment-metadata path getItemSoap() serves for fetchPdfAttachments. */ function getItemBodySoap(itemIds: string[]): string { const ids = itemIds.map((id) => ``).join(''); return soapEnvelope(` IdOnly ${ids} `); } function markReadSoap(itemId: string, changeKey: string): string { return soapEnvelope(` true `); } function getAttachmentSoap(attachmentId: string): string { return soapEnvelope(` `); } // ─── XML helpers ───────────────────────────────────────────────────────────── function escapeXml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } /** Extract all text values of a tag name from an XML string (non-recursive, fast). */ function extractAll(xml: string, tag: string): string[] { const results: string[] = []; const open = `<${tag}`; const close = ``; let pos = 0; while (pos < xml.length) { const start = xml.indexOf(open, pos); if (start === -1) break; const end = xml.indexOf(close, start); if (end === -1) break; // Get inner text (content between > and ) const innerStart = xml.indexOf('>', start) + 1; results.push(xml.slice(innerStart, end)); pos = end + close.length; } return results; } /** Extract first value of attribute from a tag. */ function extractAttr(xml: string, tag: string, attr: string): string { const tagStart = xml.indexOf(`<${tag}`); if (tagStart === -1) return ''; const tagEnd = xml.indexOf('>', tagStart); const tagStr = xml.slice(tagStart, tagEnd + 1); const attrMatch = tagStr.match(new RegExp(`${attr}="([^"]*)"`)); return attrMatch ? attrMatch[1] : ''; } /** Extract all matching tag attributes from repeated elements. */ function extractAttrs(xml: string, tag: string, attr: string): string[] { const results: string[] = []; const open = `<${tag}`; let pos = 0; while (pos < xml.length) { const start = xml.indexOf(open, pos); if (start === -1) break; const tagEnd = xml.indexOf('>', start); const tagStr = xml.slice(start, tagEnd + 1); const match = tagStr.match(new RegExp(`${attr}="([^"]*)"`)); if (match) results.push(match[1]); pos = tagEnd + 1; } return results; } const DISTINGUISHED_FOLDER_MAP: Record = { inbox: 'inbox', posteingang: 'inbox', deleteditems: 'deleteditems', gelöschteelemente: 'deleteditems', trash: 'deleteditems', sentitems: 'sentitems', gesendet: 'sentitems', drafts: 'drafts', entwürfe: 'drafts', junk: 'junkemail', junkemail: 'junkemail', spam: 'junkemail', }; /** Returns the DistinguishedFolderId if folder is a well-known name, else null (→ needs FindFolder). */ function resolveDistinguishedFolder(folder?: string): string | null { const name = (folder ?? 'INBOX').toLowerCase().replace(/[\s_-]/g, ''); return DISTINGUISHED_FOLDER_MAP[name] ?? null; } /** Build FindFolder SOAP to resolve a subfolder by display name under a parent DistinguishedFolderId. */ function findFolderSoap(parentDistinguishedId: string, displayName: string): string { return soapEnvelope(` IdOnly `); } // ─── NTLM HTTP helper ──────────────────────────────────────────────────────── interface NtlmOptions { url: string; username: string; password: string; domain: string; workstation: string; body: string; headers: Record; rejectUnauthorized?: boolean; } function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> { return new Promise((resolve, reject) => { (httpntlm as any).post(opts, (err: Error | null, res: any) => { if (err) return reject(err); resolve({ statusCode: res.statusCode, body: res.body?.toString('utf-8') ?? '' }); }); }); } // ─── Provider ──────────────────────────────────────────────────────────────── /** * ExchangeInboxProvider — NTLM-authenticated EWS via raw SOAP over httpntlm. * * Replaces the ews-javascript-api approach which only supports Basic Auth. * Uses httpntlm to perform the NTLM challenge-response handshake transparently. * * Security: * - T-07-03: credentials never logged — only generic error messages * - T-07-05: attachment size checked before buffering (PDF-bomb mitigation) * - EWS XML is escaped before insertion into SOAP envelopes */ @Injectable() export class ExchangeInboxProvider implements InboxProvider { private readonly logger = new Logger(ExchangeInboxProvider.name); async fetchPdfAttachments(config: InboxConfig): Promise { try { return await this.fetchViaEws(config); } catch (error) { this.logger.error(`EWS inbox fetch failed: ${(error as Error).message}`); return []; } } /** * Connects via EWS, finds unread items (optionally filtered by sender — * same restriction as fetchPdfAttachments), and returns each message's * subject + HTML/text body. * * Additive method (D-02) — does NOT touch fetchViaEws (fetchPdfAttachments' * path). Marks each processed item IsRead so re-polls don't reprocess it. * * @param config Decrypted inbox connection parameters * @returns Messages with subject + body (empty array on error) */ async fetchMessages(config: InboxConfig): Promise { try { return await this.fetchMessagesViaEws(config); } catch (error) { this.logger.error(`EWS fetchMessages failed: ${(error as Error).message}`); return []; } } async testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }> { try { const folderElement = await this.resolveFolderElement(config); const soap = findItemSoap(folderElement, 1); const res = await this.ewsPost(config, soap, 'FindItem'); if (res.statusCode === 401) { return { success: false, message: '401 Unauthorized — credentials rejected or NTLM not allowed' }; } if (res.statusCode !== 200) { return { success: false, message: `HTTP ${res.statusCode}` }; } if (res.body.includes('ResponseClass="Error"')) { const msg = extractAll(res.body, 'm:MessageText')[0] ?? extractAll(res.body, 'MessageText')[0] ?? 'EWS error'; return { success: false, message: msg }; } return { success: true }; } catch (err) { const message = (err as Error).message; this.logger.error(`EWS connection test failed: ${message}`); return { success: false, message }; } } // ─── Private ─────────────────────────────────────────────────────────────── /** Resolve folder config to a EWS ParentFolderIds XML element. * Well-known names → . * Custom names (e.g. "DKV" or "INBOX/DKV") → FindFolder deep search under msgfolderroot → . * Searches msgfolderroot (full mailbox) so folders at root level are found, not just inbox subfolders. */ private async resolveFolderElement(config: InboxConfig): Promise { const folderCfg = config.folder ?? 'INBOX'; // Strip leading "INBOX/" prefix — EWS FindFolder searches deep, name alone suffices const displayName = folderCfg.replace(/^INBOX\//i, '').trim(); const distinguished = resolveDistinguishedFolder(displayName); if (distinguished) { return ``; } // Custom subfolder: search entire mailbox (msgfolderroot) so top-level folders are found too const ffSoap = findFolderSoap('msgfolderroot', displayName); const ffRes = await this.ewsPost(config, ffSoap, 'FindFolder'); if (ffRes.statusCode !== 200) { this.logger.warn(`EWS FindFolder HTTP ${ffRes.statusCode} for "${displayName}" — falling back to inbox`); return ``; } this.logger.debug(`EWS FindFolder response for "${displayName}": ${ffRes.body.slice(0, 500)}`); const folderId = extractAttr(ffRes.body, 't:FolderId', 'Id'); if (!folderId) { this.logger.warn(`EWS FindFolder: subfolder "${displayName}" not found under msgfolderroot — falling back to inbox`); return ``; } this.logger.log(`EWS FindFolder: resolved "${displayName}" → FolderId ${folderId.slice(0, 20)}…`); return ``; } private async fetchViaEws(config: InboxConfig): Promise { const folderElement = await this.resolveFolderElement(config); // 1. FindItem — get IDs of emails with attachments const findSoap = findItemSoap(folderElement, 50, config.senderFilter); const findRes = await this.ewsPost(config, findSoap, 'FindItem'); if (findRes.statusCode !== 200) { this.logger.warn(`EWS FindItem returned HTTP ${findRes.statusCode}`); return []; } // Parse item IDs and HasAttachments flag from FindItem response const rawIds = extractAttrs(findRes.body, 't:ItemId', 'Id'); if (rawIds.length === 0) return []; // Only fetch items that have attachments const hasAttachFlags = extractAll(findRes.body, 't:HasAttachments'); const itemIds = rawIds.filter((_, i) => hasAttachFlags[i] === 'true'); if (itemIds.length === 0) return []; const results: InboxEmail[] = []; // 2. GetItem in batches of 10 to load attachment metadata for (let i = 0; i < itemIds.length; i += 10) { const batch = itemIds.slice(i, i + 10); const getRes = await this.ewsPost(config, getItemSoap(batch), 'GetItem'); if (getRes.statusCode !== 200) continue; // Parse each Message element from GetItem response const messageBlocks = this.splitMessageBlocks(getRes.body); for (const block of messageBlocks) { const uid = extractAttr(block, 't:ItemId', 'Id'); const changeKey = extractAttr(block, 't:ItemId', 'ChangeKey'); const subject = extractAll(block, 't:Subject')[0] ?? ''; const messageId = extractAll(block, 't:InternetMessageId')[0] ?? ''; const from = (extractAttr(block, 't:Mailbox', 'SmtpAddress') || extractAll(block, 't:EmailAddress')[0]) ?? ''; const dateStr = extractAll(block, 't:DateTimeReceived')[0] ?? ''; const date = dateStr ? new Date(dateStr) : new Date(); // Filter by sender if provided (client-side fallback for case-sensitivity) if (config.senderFilter && from && !from.toLowerCase().includes(config.senderFilter.toLowerCase())) { continue; } // Collect PDF attachment IDs by iterating each FileAttachment block. // extractAttrs(block, 't:FileAttachment', 'Id') was wrong — the Id lives // inside a child not on the FileAttachment tag. const attachmentIds: string[] = []; const FA_OPEN = ''; const FA_CLOSE = ''; let faPos = 0; while (faPos < block.length) { const faStart = block.indexOf(FA_OPEN, faPos); if (faStart === -1) break; const faEnd = block.indexOf(FA_CLOSE, faStart); if (faEnd === -1) break; const faBlock = block.slice(faStart, faEnd); const attId = extractAttr(faBlock, 't:AttachmentId', 'Id'); const ct = (extractAll(faBlock, 't:ContentType')[0] ?? '').toLowerCase(); const nm = (extractAll(faBlock, 't:Name')[0] ?? '').toLowerCase(); if (attId && (ct.includes('pdf') || nm.endsWith('.pdf'))) { attachmentIds.push(attId); } faPos = faEnd + FA_CLOSE.length; } if (attachmentIds.length === 0) continue; // 3. GetAttachment for each PDF const attachments: InboxAttachment[] = []; for (const attId of attachmentIds) { const attRes = await this.ewsPost(config, getAttachmentSoap(attId), 'GetAttachment'); if (attRes.statusCode !== 200) continue; const name = extractAll(attRes.body, 't:Name')[0] ?? 'attachment.pdf'; const content = extractAll(attRes.body, 't:Content')[0] ?? ''; if (!content) continue; const buffer = Buffer.from(content, 'base64'); if (buffer.length > MAX_ATTACHMENT_BYTES) { this.logger.warn(`Skipped EWS attachment "${name}" — exceeds size limit (T-07-05)`); continue; } attachments.push({ filename: name, contentType: 'application/pdf', buffer }); } if (attachments.length === 0) continue; results.push({ uid, messageId, subject, from, date, attachments }); // Mark as read so subsequent polls skip this message (mirrors IMAP \Seen flag). if (uid && changeKey) { try { await this.ewsPost(config, markReadSoap(uid, changeKey), 'UpdateItem'); } catch { // Non-fatal: message will reappear on next poll but IsRead filter will catch it } } } } return results; } private async fetchMessagesViaEws(config: InboxConfig): Promise { const folderElement = await this.resolveFolderElement(config); // 1. FindItem — get IDs of unread emails (no attachment filter — unlike fetchViaEws) const findSoap = findItemSoap(folderElement, 50, config.senderFilter); const findRes = await this.ewsPost(config, findSoap, 'FindItem'); if (findRes.statusCode !== 200) { this.logger.warn(`EWS FindItem (fetchMessages) returned HTTP ${findRes.statusCode}`); return []; } const itemIds = extractAttrs(findRes.body, 't:ItemId', 'Id'); if (itemIds.length === 0) return []; const results: InboxMessage[] = []; // 2. GetItem in batches of 10, requesting item:Body instead of item:Attachments for (let i = 0; i < itemIds.length; i += 10) { const batch = itemIds.slice(i, i + 10); const getRes = await this.ewsPost(config, getItemBodySoap(batch), 'GetItem'); if (getRes.statusCode !== 200) continue; const messageBlocks = this.splitMessageBlocks(getRes.body); for (const block of messageBlocks) { const uid = extractAttr(block, 't:ItemId', 'Id'); const changeKey = extractAttr(block, 't:ItemId', 'ChangeKey'); const subject = extractAll(block, 't:Subject')[0] ?? ''; const messageId = extractAll(block, 't:InternetMessageId')[0] ?? ''; const from = (extractAttr(block, 't:Mailbox', 'SmtpAddress') || extractAll(block, 't:EmailAddress')[0]) ?? ''; const dateStr = extractAll(block, 't:DateTimeReceived')[0] ?? ''; const date = dateStr ? new Date(dateStr) : new Date(); // Filter by sender if provided (client-side fallback, mirrors fetchViaEws) if (config.senderFilter && from && !from.toLowerCase().includes(config.senderFilter.toLowerCase())) { continue; } const bodyType = extractAttr(block, 't:Body', 'BodyType'); const bodyContent = extractAll(block, 't:Body')[0] ?? ''; const bodyHtml = bodyType === 'HTML' ? bodyContent : null; const bodyText = bodyType === 'Text' ? bodyContent : ''; results.push({ uid, messageId, subject, from, date, bodyHtml, bodyText }); // Mark as read so subsequent polls skip this message (mirrors fetchViaEws). if (uid && changeKey) { try { await this.ewsPost(config, markReadSoap(uid, changeKey), 'UpdateItem'); } catch { // Non-fatal: message will reappear on next poll but IsRead filter will catch it } } } } return results; } /** Split a GetItem response body into per-message XML blocks. */ private splitMessageBlocks(xml: string): string[] { const blocks: string[] = []; const open = ''; const close = ''; let pos = 0; while (pos < xml.length) { const start = xml.indexOf(open, pos); if (start === -1) break; const end = xml.indexOf(close, start); if (end === -1) break; blocks.push(xml.slice(start + open.length, end)); pos = end + close.length; } // If no blocks, treat whole response as one block return blocks.length > 0 ? blocks : [xml]; } /** POST a SOAP body to the EWS endpoint using NTLM authentication. */ private async ewsPost( config: InboxConfig, soap: string, action: string, ): Promise<{ statusCode: number; body: string }> { const opts: NtlmOptions = { url: config.host, // must be full EWS URL: https://server/EWS/Exchange.asmx username: config.username ?? '', password: config.password ?? '', domain: config.domain ?? '', workstation: '', body: soap, headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': `"http://schemas.microsoft.com/exchange/services/2006/messages/${action}"`, }, }; return ntlmPost(opts); } }