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 = `${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 )
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);
}
}