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:
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user