From b3f21a87478ab59626f5b52af8f7cf0f82d65459 Mon Sep 17 00:00:00 2001 From: Schalli Date: Sat, 27 Jun 2026 00:02:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(07-02):=20ImapProvider=20=E2=80=94=20IMAP?= =?UTF-8?q?=20inbox=20access=20via=20imapflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implements InboxProvider contract (fetchPdfAttachments + testConnection) - fetchAll() called before any download() — avoids IMAP connection deadlock (Pitfall 1) - ImapFlow constructed with logger:false — credential safety (T-07-03) - 25MB attachment size guard in streamToBuffer — PDF-bomb mitigation (T-07-05) - collectPdfParts() recursively traverses MIME tree for application/pdf parts - Generic error messages only — no credential values in logs (T-07-03) - secure/requireTLS flags derived from encryption field (ssl-tls vs starttls) --- apps/api/src/dkv/providers/imap.provider.ts | 224 ++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 apps/api/src/dkv/providers/imap.provider.ts diff --git a/apps/api/src/dkv/providers/imap.provider.ts b/apps/api/src/dkv/providers/imap.provider.ts new file mode 100644 index 0000000..a83dda8 --- /dev/null +++ b/apps/api/src/dkv/providers/imap.provider.ts @@ -0,0 +1,224 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ImapFlow, MessageStructureObject } from 'imapflow'; +import type { InboxAttachment, InboxConfig, InboxEmail } 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; + + // Check this node — type is the full MIME type (e.g. "application/pdf") + if ( + node.type?.toLowerCase() === 'application/pdf' && + 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; +} + +/** + * 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(); + const lock = await client.getMailboxLock(config.folder || 'INBOX'); + const results: InboxEmail[] = []; + + try { + // Search for emails from the configured sender + const searchQuery = config.senderFilter + ? { 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, + }); + } + } + } 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 { + const client = this.buildClient(config); + try { + await client.connect(); + await client.logout(); + return true; + } catch (err) { + // T-07-03: generic error message — no credential details + this.logger.error( + `IMAP connection test failed: ${(err as Error).message}`, + ); + return false; + } + } + + /** + * 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); + } +}