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,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.
|
||||
|
||||
Reference in New Issue
Block a user