feat(dkv): Exchange subfolder support via EWS FindFolder
Tessera CI/CD / Lint & Type Check (push) Successful in 40s
Tessera CI/CD / Tests (push) Successful in 39s
Tessera CI/CD / Build & Publish Images (push) Successful in 22s

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:
2026-06-30 10:04:24 +02:00
parent b5bf3ed8c0
commit 30eb40c184
@@ -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,10 +158,7 @@ function extractAttrs(xml: string, tag: string, attr: string): string[] {
return results;
}
/** Map configured folder name to EWS DistinguishedFolderId. */
function resolveDistinguishedFolder(folder?: string): string {
const name = (folder ?? 'INBOX').toLowerCase().replace(/[\s_-]/g, '');
const map: Record<string, string> = {
const DISTINGUISHED_FOLDER_MAP: Record<string, string> = {
inbox: 'inbox',
posteingang: 'inbox',
deleteditems: 'deleteditems',
@@ -174,7 +172,32 @@ function resolveDistinguishedFolder(folder?: string): string {
junkemail: 'junkemail',
spam: 'junkemail',
};
return map[name] ?? 'inbox';
/** 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 ────────────────────────────────────────────────────────
@@ -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}`);