import { Injectable, Logger } from '@nestjs/common'; import { ImapFlow, MessageStructureObject } from 'imapflow'; import type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface'; import type { InboxProvider } from './inbox-provider.interface'; /** * Max attachment size (bytes) accepted before buffering. * Prevents PDF-bomb DoS (T-07-05 — Research Security Domain). * 25 MB covers any realistic DKV invoice; larger files are skipped. */ const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB /** * Converts a Node.js Readable stream into a Buffer. * Accumulates chunks up to MAX_ATTACHMENT_BYTES; throws if limit exceeded. */ async function streamToBuffer( stream: NodeJS.ReadableStream, ): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; let total = 0; stream.on('data', (chunk: Buffer) => { total += chunk.length; if (total > MAX_ATTACHMENT_BYTES) { // Destroy the stream to prevent further data emission (stream as any).destroy?.(); reject( new Error( `Attachment exceeds maximum allowed size of ${MAX_ATTACHMENT_BYTES} bytes (T-07-05)`, ), ); return; } chunks.push(Buffer.from(chunk)); }); stream.on('end', () => resolve(Buffer.concat(chunks))); stream.on('error', reject); }); } /** * Recursively collects all PDF body-part IDs from a message's MIME structure. * * imapflow represents the MIME tree as nested `MessageStructureObject` nodes. * Each node has a `type` (full MIME type, e.g. "application/pdf") and a `part` * ID that can be passed to `client.download()`. * * @param node Root or child MIME structure node * @param parts Accumulator (pass empty array on first call) */ function collectPdfParts( node: MessageStructureObject | undefined, parts: string[] = [], ): string[] { if (!node) return parts; const type = node.type?.toLowerCase() ?? ''; // Some mail clients (e.g. Outlook) send PDFs as application/octet-stream. // Fall back to checking the filename from Content-Disposition or Content-Type parameters. const dispositionFilename = ((node as any).disposition?.parameters?.filename as string | undefined)?.toLowerCase() ?? ''; const typeFilename = ((node as any).parameters?.name as string | undefined)?.toLowerCase() ?? ''; const looksLikePdf = type === 'application/pdf' || (type === 'application/octet-stream' && (dispositionFilename.endsWith('.pdf') || typeFilename.endsWith('.pdf'))); if (looksLikePdf && node.part !== undefined) { parts.push(node.part); } // Recurse into multipart children if (Array.isArray(node.childNodes)) { for (const child of node.childNodes) { collectPdfParts(child, parts); } } 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. * * Implements InboxProvider so it is interchangeable with ExchangeInboxProvider. * * Security: * - T-07-03: ImapFlow constructed with `logger: false` (no credential logging) * - T-07-03: Error messages are generic — credentials never appear in logs * - T-07-05: Max attachment size enforced before buffering (PDF-bomb mitigation) * * Pitfall avoidance: * - Pitfall 1: `fetchAll()` is called BEFORE any `download()` calls. * Never call `download()` inside a `client.fetch()` async iterator — * that deadlocks the IMAP connection. */ @Injectable() export class ImapProvider implements InboxProvider { private readonly logger = new Logger(ImapProvider.name); /** * Connects to the IMAP server, locks the configured folder, searches for * emails from the configured sender, and downloads PDF attachments. * * @param config Decrypted inbox connection parameters * @returns Emails with PDF attachments as Buffers (empty array on error) */ async fetchPdfAttachments(config: InboxConfig): Promise { const client = this.buildClient(config); await client.connect(); // lock is declared outside try so the type is available in finally, but // getMailboxLock() itself is inside the try so that a lock failure still // triggers client.logout() — preventing a connection leak (CR-03). let lock: Awaited> | null = null; const results: InboxEmail[] = []; try { lock = await client.getMailboxLock(config.folder || 'INBOX'); // Search only UNSEEN emails to avoid reprocessing already-handled messages. // Combine with sender filter when configured. const searchQuery: Record = { seen: false }; if (config.senderFilter) { searchQuery.from = config.senderFilter; } const uids = await client.search(searchQuery, { uid: true }); if (!uids || uids.length === 0) { return results; } // Fetch envelope + body structure for all matching UIDs // IMPORTANT: Must call fetchAll() and complete BEFORE any download() calls // Pitfall 1: calling download() inside a fetch() iterator deadlocks the connection const messages = await client.fetchAll( uids.join(','), { envelope: true, bodyStructure: true }, { uid: true }, ); for (const msg of messages) { // Find all PDF parts in the message MIME tree const pdfPartIds = collectPdfParts(msg.bodyStructure); const attachments: InboxAttachment[] = []; for (const partId of pdfPartIds) { try { const { content } = await client.download( String(msg.uid), partId, { uid: true }, ); // streamToBuffer enforces MAX_ATTACHMENT_BYTES (T-07-05) const buf = await streamToBuffer(content); const filename = `attachment-${msg.uid}-${partId}.pdf`; attachments.push({ filename, contentType: 'application/pdf', buffer: buf, }); } catch (partErr) { // Skip oversized or unreadable parts; log generic message (T-07-03) this.logger.warn( `Skipped IMAP attachment part ${partId} for UID ${String(msg.uid)}: ${(partErr as Error).message}`, ); } } if (attachments.length > 0) { 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(), attachments, }); // Mark as read so subsequent polls skip this message (UNSEEN filter above). try { await client.messageFlagsAdd(String(msg.uid), ['\\Seen'], { uid: true }); } catch { // Non-fatal: message will simply appear again on next poll } } } } finally { lock?.release(); await client.logout(); } 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 { 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> | null = null; try { lock = await client.getMailboxLock(config.folder || 'INBOX'); const searchQuery: Record = { 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. * * @param config Decrypted inbox connection parameters * @returns true on success, false on any network/auth failure */ async testConnection(config: InboxConfig): Promise<{ success: boolean; message?: string }> { const client = this.buildClient(config); try { await client.connect(); await client.logout(); return { success: true }; } catch (err) { const message = (err as Error).message; // T-07-03: log without credentials; return message to admin for diagnosis this.logger.error(`IMAP connection test failed: ${message}`); return { success: false, message }; } } /** * Constructs an ImapFlow client from InboxConfig. * * Security (T-07-03): * - `logger: false` suppresses imapflow verbose logging (which includes credentials) * - `auth` is only set when username is present (supports servers with no auth) */ private buildClient(config: InboxConfig): ImapFlow { return new ImapFlow({ host: config.host, port: config.port, // ssl-tls = implicit TLS (port 993); starttls = STARTTLS upgrade (port 143) secure: config.encryption === 'ssl-tls', requireTLS: config.encryption === 'starttls', auth: config.username ? { user: config.username, pass: config.password ?? '' } : undefined, // T-07-03: suppress imapflow verbose logs — they include auth credentials logger: false, } as any); } }