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>`; </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,10 +158,7 @@ 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 {
const name = (folder ?? 'INBOX').toLowerCase().replace(/[\s_-]/g, '');
const map: Record<string, string> = {
inbox: 'inbox', inbox: 'inbox',
posteingang: 'inbox', posteingang: 'inbox',
deleteditems: 'deleteditems', deleteditems: 'deleteditems',
@@ -173,8 +171,33 @@ function resolveDistinguishedFolder(folder?: string): string {
junk: 'junkemail', junk: 'junkemail',
junkemail: 'junkemail', junkemail: 'junkemail',
spam: '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 ──────────────────────────────────────────────────────── // ─── 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}`);