refactor(14-01): extract DKV inbox providers into shared inbox/ module

- Move ImapProvider, ExchangeInboxProvider, InboxProvider into apps/api/src/inbox/
- Move InboxConfig/InboxAttachment/InboxEmail into new inbox.types.ts
- dkv.types.ts re-exports the moved types so existing DKV imports keep compiling
- DKV switches import paths to ../inbox/... and imports InboxModule
- Pure move + import-path swap: fetchPdfAttachments and all DKV logic unchanged (D-01/D-02)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:02:15 +02:00
parent 9dd5038575
commit eb668fd5c8
8 changed files with 106 additions and 51 deletions
+243
View File
@@ -0,0 +1,243 @@
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<Buffer> {
return new Promise<Buffer>((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;
}
/**
* 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<InboxEmail[]> {
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<ReturnType<typeof client.getMailboxLock>> | 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<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;
}
// 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;
}
/**
* 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);
}
}