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>`;
|
</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>
|
const isReadFilter = `<t:IsEqualTo>
|
||||||
<t:FieldURI FieldURI="message:IsRead"/>
|
<t:FieldURI FieldURI="message:IsRead"/>
|
||||||
<t:FieldURIOrConstant><t:Constant Value="false"/></t:FieldURIOrConstant>
|
<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"/>
|
<m:IndexedPageItemView MaxEntriesReturned="${maxResults}" Offset="0" BasePoint="Beginning"/>
|
||||||
${restriction}
|
${restriction}
|
||||||
<m:ParentFolderIds>
|
<m:ParentFolderIds>
|
||||||
<t:DistinguishedFolderId Id="${escapeXml(folderId)}"/>
|
${folderElement}
|
||||||
</m:ParentFolderIds>
|
</m:ParentFolderIds>
|
||||||
</m:FindItem>`);
|
</m:FindItem>`);
|
||||||
}
|
}
|
||||||
@@ -157,24 +158,46 @@ function extractAttrs(xml: string, tag: string, attr: string): string[] {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map configured folder name to EWS DistinguishedFolderId. */
|
const DISTINGUISHED_FOLDER_MAP: Record<string, string> = {
|
||||||
function resolveDistinguishedFolder(folder?: 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 name = (folder ?? 'INBOX').toLowerCase().replace(/[\s_-]/g, '');
|
||||||
const map: Record<string, string> = {
|
return DISTINGUISHED_FOLDER_MAP[name] ?? null;
|
||||||
inbox: 'inbox',
|
}
|
||||||
posteingang: 'inbox',
|
|
||||||
deleteditems: 'deleteditems',
|
/** Build FindFolder SOAP to resolve a subfolder by display name under a parent DistinguishedFolderId. */
|
||||||
gelöschteelemente: 'deleteditems',
|
function findFolderSoap(parentDistinguishedId: string, displayName: string): string {
|
||||||
trash: 'deleteditems',
|
return soapEnvelope(`
|
||||||
sentitems: 'sentitems',
|
<m:FindFolder Traversal="Deep">
|
||||||
gesendet: 'sentitems',
|
<m:FolderShape>
|
||||||
drafts: 'drafts',
|
<t:BaseShape>IdOnly</t:BaseShape>
|
||||||
entwürfe: 'drafts',
|
</m:FolderShape>
|
||||||
junk: 'junkemail',
|
<m:Restriction>
|
||||||
junkemail: 'junkemail',
|
<t:IsEqualTo>
|
||||||
spam: 'junkemail',
|
<t:FieldURI FieldURI="folder:DisplayName"/>
|
||||||
};
|
<t:FieldURIOrConstant>
|
||||||
return map[name] ?? 'inbox';
|
<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 ────────────────────────────────────────────────────────
|
// ─── NTLM HTTP helper ────────────────────────────────────────────────────────
|
||||||
@@ -227,8 +250,8 @@ export class ExchangeInboxProvider implements InboxProvider {
|
|||||||
|
|
||||||
async testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }> {
|
async testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }> {
|
||||||
try {
|
try {
|
||||||
const folder = resolveDistinguishedFolder(config.folder);
|
const folderElement = await this.resolveFolderElement(config);
|
||||||
const soap = findItemSoap(folder, 1);
|
const soap = findItemSoap(folderElement, 1);
|
||||||
const res = await this.ewsPost(config, soap, 'FindItem');
|
const res = await this.ewsPost(config, soap, 'FindItem');
|
||||||
|
|
||||||
if (res.statusCode === 401) {
|
if (res.statusCode === 401) {
|
||||||
@@ -251,11 +274,34 @@ export class ExchangeInboxProvider implements InboxProvider {
|
|||||||
|
|
||||||
// ─── Private ───────────────────────────────────────────────────────────────
|
// ─── 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[]> {
|
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
|
// 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');
|
const findRes = await this.ewsPost(config, findSoap, 'FindItem');
|
||||||
if (findRes.statusCode !== 200) {
|
if (findRes.statusCode !== 200) {
|
||||||
this.logger.warn(`EWS FindItem returned HTTP ${findRes.statusCode}`);
|
this.logger.warn(`EWS FindItem returned HTTP ${findRes.statusCode}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user