c404954bee
- Add InboxMessage type (subject + html/text body) and fetchMessages() to the InboxProvider interface, ImapProvider, and ExchangeInboxProvider - IMAP: findBodyParts() walks the MIME tree for first text/html + text/plain parts, reusing the connect/lock/search/fetchAll skeleton; marks \Seen - EWS: new getItemBodySoap() requests item:Body, extracts BodyType via the existing extractAttr/extractAll helpers, marks IsRead via markReadSoap - Net-new spec coverage (imap.provider.spec.ts, exchange-inbox.provider.spec.ts) mocking ImapFlow and httpntlm.post (via require.cache stub, since httpntlm is loaded with a raw require() that vi.mock cannot intercept) - fetchPdfAttachments untouched in both providers (D-02); full API suite (301 tests) + tsc --noEmit stay green Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
557 lines
22 KiB
TypeScript
557 lines
22 KiB
TypeScript
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 `<?xml version="1.0" encoding="utf-8"?>
|
|
<soap:Envelope xmlns:soap="${NS_SOAP}"
|
|
xmlns:t="${NS_TYPES}"
|
|
xmlns:m="${NS_MESSAGES}">
|
|
<soap:Body>${body}</soap:Body>
|
|
</soap:Envelope>`;
|
|
}
|
|
|
|
/** folderElement: either <t:DistinguishedFolderId Id="inbox"/> or <t:FolderId Id="AAA..."/> */
|
|
function findItemSoap(folderElement: string, maxResults: number, senderFilter?: string): string {
|
|
const isReadFilter = `<t:IsEqualTo>
|
|
<t:FieldURI FieldURI="message:IsRead"/>
|
|
<t:FieldURIOrConstant><t:Constant Value="false"/></t:FieldURIOrConstant>
|
|
</t:IsEqualTo>`;
|
|
|
|
const restriction = senderFilter
|
|
? `<m:Restriction>
|
|
<t:And>
|
|
${isReadFilter}
|
|
<t:Contains ContainmentMode="Substring" ContainmentComparison="IgnoreCase">
|
|
<t:FieldURI FieldURI="message:From"/>
|
|
<t:Constant Value="${escapeXml(senderFilter)}"/>
|
|
</t:Contains>
|
|
</t:And>
|
|
</m:Restriction>`
|
|
: `<m:Restriction>${isReadFilter}</m:Restriction>`;
|
|
|
|
return soapEnvelope(`
|
|
<m:FindItem Traversal="Shallow">
|
|
<m:ItemShape>
|
|
<t:BaseShape>IdOnly</t:BaseShape>
|
|
<t:AdditionalProperties>
|
|
<t:FieldURI FieldURI="item:Subject"/>
|
|
<t:FieldURI FieldURI="message:InternetMessageId"/>
|
|
<t:FieldURI FieldURI="message:From"/>
|
|
<t:FieldURI FieldURI="item:DateTimeReceived"/>
|
|
<t:FieldURI FieldURI="item:HasAttachments"/>
|
|
</t:AdditionalProperties>
|
|
</m:ItemShape>
|
|
<m:IndexedPageItemView MaxEntriesReturned="${maxResults}" Offset="0" BasePoint="Beginning"/>
|
|
${restriction}
|
|
<m:ParentFolderIds>
|
|
${folderElement}
|
|
</m:ParentFolderIds>
|
|
</m:FindItem>`);
|
|
}
|
|
|
|
function getItemSoap(itemIds: string[]): string {
|
|
const ids = itemIds.map((id) => `<t:ItemId Id="${escapeXml(id)}"/>`).join('');
|
|
return soapEnvelope(`
|
|
<m:GetItem>
|
|
<m:ItemShape>
|
|
<t:BaseShape>IdOnly</t:BaseShape>
|
|
<t:AdditionalProperties>
|
|
<t:FieldURI FieldURI="item:Subject"/>
|
|
<t:FieldURI FieldURI="message:InternetMessageId"/>
|
|
<t:FieldURI FieldURI="message:From"/>
|
|
<t:FieldURI FieldURI="item:DateTimeReceived"/>
|
|
<t:FieldURI FieldURI="item:Attachments"/>
|
|
</t:AdditionalProperties>
|
|
</m:ItemShape>
|
|
<m:ItemIds>${ids}</m:ItemIds>
|
|
</m:GetItem>`);
|
|
}
|
|
|
|
/**
|
|
* 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) => `<t:ItemId Id="${escapeXml(id)}"/>`).join('');
|
|
return soapEnvelope(`
|
|
<m:GetItem>
|
|
<m:ItemShape>
|
|
<t:BaseShape>IdOnly</t:BaseShape>
|
|
<t:AdditionalProperties>
|
|
<t:FieldURI FieldURI="item:Subject"/>
|
|
<t:FieldURI FieldURI="message:InternetMessageId"/>
|
|
<t:FieldURI FieldURI="message:From"/>
|
|
<t:FieldURI FieldURI="item:DateTimeReceived"/>
|
|
<t:FieldURI FieldURI="item:Body"/>
|
|
</t:AdditionalProperties>
|
|
</m:ItemShape>
|
|
<m:ItemIds>${ids}</m:ItemIds>
|
|
</m:GetItem>`);
|
|
}
|
|
|
|
function markReadSoap(itemId: string, changeKey: string): string {
|
|
return soapEnvelope(`
|
|
<m:UpdateItem MessageDisposition="SaveOnly" ConflictResolution="AlwaysOverwrite">
|
|
<m:ItemChanges>
|
|
<t:ItemChange>
|
|
<t:ItemId Id="${escapeXml(itemId)}" ChangeKey="${escapeXml(changeKey)}"/>
|
|
<t:Updates>
|
|
<t:SetItemField>
|
|
<t:FieldURI FieldURI="message:IsRead"/>
|
|
<t:Message><t:IsRead>true</t:IsRead></t:Message>
|
|
</t:SetItemField>
|
|
</t:Updates>
|
|
</t:ItemChange>
|
|
</m:ItemChanges>
|
|
</m:UpdateItem>`);
|
|
}
|
|
|
|
function getAttachmentSoap(attachmentId: string): string {
|
|
return soapEnvelope(`
|
|
<m:GetAttachment>
|
|
<m:AttachmentIds>
|
|
<t:AttachmentId Id="${escapeXml(attachmentId)}"/>
|
|
</m:AttachmentIds>
|
|
</m:GetAttachment>`);
|
|
}
|
|
|
|
// ─── XML helpers ─────────────────────────────────────────────────────────────
|
|
|
|
function escapeXml(s: string): string {
|
|
return s.replace(/&/g, '&').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 = `</${tag}>`;
|
|
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 </tag>)
|
|
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<string, string> = {
|
|
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(`
|
|
<m:FindFolder Traversal="Deep">
|
|
<m:FolderShape>
|
|
<t:BaseShape>IdOnly</t:BaseShape>
|
|
</m:FolderShape>
|
|
<m:Restriction>
|
|
<t:IsEqualTo>
|
|
<t:FieldURI FieldURI="folder:DisplayName"/>
|
|
<t:FieldURIOrConstant>
|
|
<t:Constant Value="${escapeXml(displayName)}"/>
|
|
</t:FieldURIOrConstant>
|
|
</t:IsEqualTo>
|
|
</m:Restriction>
|
|
<m:ParentFolderIds>
|
|
<t:DistinguishedFolderId Id="${escapeXml(parentDistinguishedId)}"/>
|
|
</m:ParentFolderIds>
|
|
</m:FindFolder>`);
|
|
}
|
|
|
|
// ─── NTLM HTTP helper ────────────────────────────────────────────────────────
|
|
|
|
interface NtlmOptions {
|
|
url: string;
|
|
username: string;
|
|
password: string;
|
|
domain: string;
|
|
workstation: string;
|
|
body: string;
|
|
headers: Record<string, string>;
|
|
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<InboxEmail[]> {
|
|
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<InboxMessage[]> {
|
|
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 → <t:DistinguishedFolderId>.
|
|
* Custom names (e.g. "DKV" or "INBOX/DKV") → FindFolder deep search under msgfolderroot → <t:FolderId>.
|
|
* Searches msgfolderroot (full mailbox) so folders at root level are found, not just inbox subfolders.
|
|
*/
|
|
private async resolveFolderElement(config: InboxConfig): Promise<string> {
|
|
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 `<t:DistinguishedFolderId Id="${escapeXml(distinguished)}"/>`;
|
|
}
|
|
// 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 `<t:DistinguishedFolderId Id="inbox"/>`;
|
|
}
|
|
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 `<t:DistinguishedFolderId Id="inbox"/>`;
|
|
}
|
|
this.logger.log(`EWS FindFolder: resolved "${displayName}" → FolderId ${folderId.slice(0, 20)}…`);
|
|
return `<t:FolderId Id="${escapeXml(folderId)}"/>`;
|
|
}
|
|
|
|
private async fetchViaEws(config: InboxConfig): Promise<InboxEmail[]> {
|
|
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 <t:AttachmentId Id="..."/> not on the FileAttachment tag.
|
|
const attachmentIds: string[] = [];
|
|
const FA_OPEN = '<t:FileAttachment>';
|
|
const FA_CLOSE = '</t:FileAttachment>';
|
|
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<InboxMessage[]> {
|
|
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 = '<m:Items>';
|
|
const close = '</m:Items>';
|
|
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 <m:Items> 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);
|
|
}
|
|
}
|