feat(dkv): Exchange subfolder support via EWS FindFolder
Custom folder names (e.g. "DKV" or "INBOX/DKV") now resolved by calling EWS FindFolder deep-search under inbox. Well-known names still map to DistinguishedFolderId directly. Falls back to inbox with a warning log when the subfolder cannot be found. IMAP already supported subfolder paths natively via ImapFlow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,8 @@ function soapEnvelope(body: string): string {
|
||||
</soap:Envelope>`;
|
||||
}
|
||||
|
||||
function findItemSoap(folderId: string, maxResults: number, senderFilter?: string): string {
|
||||
/** 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>
|
||||
@@ -56,7 +57,7 @@ function findItemSoap(folderId: string, maxResults: number, senderFilter?: strin
|
||||
<m:IndexedPageItemView MaxEntriesReturned="${maxResults}" Offset="0" BasePoint="Beginning"/>
|
||||
${restriction}
|
||||
<m:ParentFolderIds>
|
||||
<t:DistinguishedFolderId Id="${escapeXml(folderId)}"/>
|
||||
${folderElement}
|
||||
</m:ParentFolderIds>
|
||||
</m:FindItem>`);
|
||||
}
|
||||
@@ -157,24 +158,46 @@ function extractAttrs(xml: string, tag: string, attr: string): string[] {
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Map configured folder name to EWS DistinguishedFolderId. */
|
||||
function resolveDistinguishedFolder(folder?: string): string {
|
||||
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, '');
|
||||
const 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',
|
||||
};
|
||||
return map[name] ?? 'inbox';
|
||||
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 ────────────────────────────────────────────────────────
|
||||
@@ -227,8 +250,8 @@ export class ExchangeInboxProvider implements InboxProvider {
|
||||
|
||||
async testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const folder = resolveDistinguishedFolder(config.folder);
|
||||
const soap = findItemSoap(folder, 1);
|
||||
const folderElement = await this.resolveFolderElement(config);
|
||||
const soap = findItemSoap(folderElement, 1);
|
||||
const res = await this.ewsPost(config, soap, 'FindItem');
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
@@ -251,11 +274,34 @@ export class ExchangeInboxProvider implements InboxProvider {
|
||||
|
||||
// ─── 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 inbox → <t:FolderId>.
|
||||
*/
|
||||
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: resolve via FindFolder
|
||||
const ffSoap = findFolderSoap('inbox', displayName);
|
||||
const ffRes = await this.ewsPost(config, ffSoap, 'FindFolder');
|
||||
const folderId = extractAttr(ffRes.body, 't:FolderId', 'Id');
|
||||
if (!folderId) {
|
||||
this.logger.warn(`EWS FindFolder: subfolder "${displayName}" not found — falling back to inbox`);
|
||||
return `<t:DistinguishedFolderId Id="inbox"/>`;
|
||||
}
|
||||
return `<t:FolderId Id="${escapeXml(folderId)}"/>`;
|
||||
}
|
||||
|
||||
private async fetchViaEws(config: InboxConfig): Promise<InboxEmail[]> {
|
||||
const folder = resolveDistinguishedFolder(config.folder);
|
||||
const folderElement = await this.resolveFolderElement(config);
|
||||
|
||||
// 1. FindItem — get IDs of emails with attachments
|
||||
const findSoap = findItemSoap(folder, 50, config.senderFilter);
|
||||
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}`);
|
||||
|
||||
Reference in New Issue
Block a user