feat(14-01): add fetchMessages() to InboxProvider + both implementations

- Add InboxMessage type (subject + html/text body) and fetchMessages() to
  the InboxProvider interface, ImapProvider, and ExchangeInboxProvider
- IMAP: findBodyParts() walks the MIME tree for first text/html + text/plain
  parts, reusing the connect/lock/search/fetchAll skeleton; marks \Seen
- EWS: new getItemBodySoap() requests item:Body, extracts BodyType via the
  existing extractAttr/extractAll helpers, marks IsRead via markReadSoap
- Net-new spec coverage (imap.provider.spec.ts, exchange-inbox.provider.spec.ts)
  mocking ImapFlow and httpntlm.post (via require.cache stub, since httpntlm
  is loaded with a raw require() that vi.mock cannot intercept)
- fetchPdfAttachments untouched in both providers (D-02); full API suite
  (301 tests) + tsc --noEmit stay green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:09:45 +02:00
parent eb668fd5c8
commit c404954bee
5 changed files with 640 additions and 4 deletions
+105 -1
View File
@@ -1,7 +1,7 @@
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 { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface';
import type { InboxProvider } from './inbox-provider.interface';
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB — T-07-05
@@ -80,6 +80,29 @@ function getItemSoap(itemIds: string[]): string {
</m:GetItem>`);
}
/**
* GetItem SOAP requesting item:Body instead of item:Attachments — used by
* fetchMessages (D-02) to retrieve the HTML/text body without touching the
* attachment-metadata path getItemSoap() serves for fetchPdfAttachments.
*/
function getItemBodySoap(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:Body"/>
</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">
@@ -248,6 +271,26 @@ export class ExchangeInboxProvider implements InboxProvider {
}
}
/**
* Connects via EWS, finds unread items (optionally filtered by sender —
* same restriction as fetchPdfAttachments), and returns each message's
* subject + HTML/text body.
*
* Additive method (D-02) — does NOT touch fetchViaEws (fetchPdfAttachments'
* path). Marks each processed item IsRead so re-polls don't reprocess it.
*
* @param config Decrypted inbox connection parameters
* @returns Messages with subject + body (empty array on error)
*/
async fetchMessages(config: InboxConfig): Promise<InboxMessage[]> {
try {
return await this.fetchMessagesViaEws(config);
} catch (error) {
this.logger.error(`EWS fetchMessages failed: ${(error as Error).message}`);
return [];
}
}
async testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }> {
try {
const folderElement = await this.resolveFolderElement(config);
@@ -411,6 +454,67 @@ export class ExchangeInboxProvider implements InboxProvider {
return results;
}
private async fetchMessagesViaEws(config: InboxConfig): Promise<InboxMessage[]> {
const folderElement = await this.resolveFolderElement(config);
// 1. FindItem — get IDs of unread emails (no attachment filter — unlike fetchViaEws)
const findSoap = findItemSoap(folderElement, 50, config.senderFilter);
const findRes = await this.ewsPost(config, findSoap, 'FindItem');
if (findRes.statusCode !== 200) {
this.logger.warn(`EWS FindItem (fetchMessages) returned HTTP ${findRes.statusCode}`);
return [];
}
const itemIds = extractAttrs(findRes.body, 't:ItemId', 'Id');
if (itemIds.length === 0) return [];
const results: InboxMessage[] = [];
// 2. GetItem in batches of 10, requesting item:Body instead of item:Attachments
for (let i = 0; i < itemIds.length; i += 10) {
const batch = itemIds.slice(i, i + 10);
const getRes = await this.ewsPost(config, getItemBodySoap(batch), 'GetItem');
if (getRes.statusCode !== 200) continue;
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, mirrors fetchViaEws)
if (config.senderFilter && from &&
!from.toLowerCase().includes(config.senderFilter.toLowerCase())) {
continue;
}
const bodyType = extractAttr(block, 't:Body', 'BodyType');
const bodyContent = extractAll(block, 't:Body')[0] ?? '';
const bodyHtml = bodyType === 'HTML' ? bodyContent : null;
const bodyText = bodyType === 'Text' ? bodyContent : '';
results.push({ uid, messageId, subject, from, date, bodyHtml, bodyText });
// Mark as read so subsequent polls skip this message (mirrors fetchViaEws).
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[] = [];