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
+137 -1
View File
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { ImapFlow, MessageStructureObject } from 'imapflow';
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';
/**
@@ -82,6 +82,43 @@ function collectPdfParts(
return parts;
}
/** First-html + first-text body part IDs found in a message's MIME tree. */
interface BodyParts {
htmlPart?: string;
textPart?: string;
}
/**
* Recursively walks a message's MIME structure collecting the first
* `text/html` and first `text/plain` part IDs (D-02 — additive sibling to
* collectPdfParts, does not affect the PDF-attachment path).
*
* @param node Root or child MIME structure node
* @param found Accumulator (pass empty object on first call)
*/
function findBodyParts(
node: MessageStructureObject | undefined,
found: BodyParts = {},
): BodyParts {
if (!node) return found;
const type = node.type?.toLowerCase() ?? '';
if (type === 'text/html' && found.htmlPart === undefined && node.part !== undefined) {
found.htmlPart = node.part;
} else if (type === 'text/plain' && found.textPart === undefined && node.part !== undefined) {
found.textPart = node.part;
}
if (Array.isArray(node.childNodes)) {
for (const child of node.childNodes) {
findBodyParts(child, found);
}
}
return found;
}
/**
* IMAP inbox provider using the imapflow library.
*
@@ -197,6 +234,105 @@ export class ImapProvider implements InboxProvider {
return results;
}
/**
* Connects to the IMAP server, searches for unread emails (optionally
* filtered by sender — same query as fetchPdfAttachments), and returns
* each message's subject + HTML/text body.
*
* Additive method (D-02) — does NOT touch fetchPdfAttachments. Marks each
* processed message \Seen so re-polls don't reprocess it (idempotency).
*
* @param config Decrypted inbox connection parameters
* @returns Messages with subject + body (empty array on error)
*/
async fetchMessages(config: InboxConfig): Promise<InboxMessage[]> {
const client = this.buildClient(config);
const results: InboxMessage[] = [];
try {
await client.connect();
} catch (err) {
this.logger.error(`IMAP fetchMessages connect failed: ${(err as Error).message}`);
return results;
}
let lock: Awaited<ReturnType<typeof client.getMailboxLock>> | null = null;
try {
lock = await client.getMailboxLock(config.folder || 'INBOX');
const searchQuery: Record<string, unknown> = { seen: false };
if (config.senderFilter) {
searchQuery.from = config.senderFilter;
}
const uids = await client.search(searchQuery, { uid: true });
if (!uids || uids.length === 0) {
return results;
}
// Same fetchAll-before-download ordering as fetchPdfAttachments (Pitfall 1)
const messages = await client.fetchAll(
uids.join(','),
{ envelope: true, bodyStructure: true },
{ uid: true },
);
for (const msg of messages) {
const { htmlPart, textPart } = findBodyParts(msg.bodyStructure);
let bodyHtml: string | null = null;
let bodyText = '';
if (htmlPart !== undefined) {
try {
const { content } = await client.download(String(msg.uid), htmlPart, { uid: true });
bodyHtml = (await streamToBuffer(content)).toString('utf8');
} catch (partErr) {
this.logger.warn(
`Skipped IMAP html body part for UID ${String(msg.uid)}: ${(partErr as Error).message}`,
);
}
}
if (textPart !== undefined) {
try {
const { content } = await client.download(String(msg.uid), textPart, { uid: true });
bodyText = (await streamToBuffer(content)).toString('utf8');
} catch (partErr) {
this.logger.warn(
`Skipped IMAP text body part for UID ${String(msg.uid)}: ${(partErr as Error).message}`,
);
}
}
results.push({
uid: msg.uid!,
messageId: msg.envelope?.messageId ?? '',
subject: msg.envelope?.subject ?? '',
from: msg.envelope?.from?.[0]?.address ?? '',
date: msg.envelope?.date ?? new Date(),
bodyHtml,
bodyText,
});
// Mark as read so subsequent polls skip this message (idempotency).
try {
await client.messageFlagsAdd(String(msg.uid), ['\\Seen'], { uid: true });
} catch {
// Non-fatal: message will simply appear again on next poll
}
}
} catch (err) {
this.logger.error(`IMAP fetchMessages failed: ${(err as Error).message}`);
} finally {
lock?.release();
await client.logout();
}
return results;
}
/**
* Tests whether the IMAP connection can be established.
* Connects and immediately logs out without selecting any folder.