refactor(14-01): extract DKV inbox providers into shared inbox/ module
- Move ImapProvider, ExchangeInboxProvider, InboxProvider into apps/api/src/inbox/ - Move InboxConfig/InboxAttachment/InboxEmail into new inbox.types.ts - dkv.types.ts re-exports the moved types so existing DKV imports keep compiling - DKV switches import paths to ../inbox/... and imports InboxModule - Pure move + import-path swap: fetchPdfAttachments and all DKV logic unchanged (D-01/D-02) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
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 } 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>`);
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user