d0266bf86e
B-06: requireTLS durch doSTARTTLS ersetzt. requireTLS kennt imapflow 1.4.3 nicht und verwirft es still; ein als STARTTLS eingerichtetes Postfach konnte deshalb unbemerkt im Klartext verbinden. Gewollte Folge: so ein Postfach scheitert jetzt, wenn der Server kein STARTTLS anbietet. Bei ssl-tls ergibt der Ausdruck false, was die Unvertraeglichkeit secure=true + doSTARTTLS=true gar nicht erst entstehen laesst. B-05: Dateiname aus Content-Disposition kommt jetzt aus dem Feld, in dem imapflow ihn ablegt (dispositionParameters), statt aus .parameters einer Zeichenkette. Der alte Ausdruck war zur Laufzeit immer undefined, wodurch Anhaenge als application/octet-stream (typisch Outlook) nicht erkannt wurden. Die Zusicherung am Ende von buildClient() entfaellt ersatzlos; sie bestand nur wegen der unbekannten Option. noExplicitAny in apps/api/src faellt damit von 15 auf 13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import type { Readable } from 'node:stream';
|
|
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: Readable): 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.
|
|
// Readable statt NodeJS.ReadableStream: alle drei Aufrufer reichen
|
|
// client.download().content herein, und imapflow deklariert das als
|
|
// Readable (imap-flow.d.ts:521). Readable traegt destroy(), also
|
|
// braucht der Aufruf keine Zusicherung mehr.
|
|
stream.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.
|
|
// Repariert in 260921-oxm (Befund B-05 aus 260921-m34): hier stand zuvor
|
|
// `disposition?.parameters?.filename`, auf einem zu any umgedeuteten Knoten.
|
|
// imapflow deklariert `disposition` aber als ZEICHENKETTE (imap-flow.d.ts:448, also
|
|
// "attachment"/"inline") und legt die zugehoerigen Parameter in ein eigenes
|
|
// Feld `dispositionParameters` (:450, gefuellt in tools.js:887 mit
|
|
// kleingeschriebenen Schluesseln). Der alte Ausdruck las `.parameters` von
|
|
// einer Zeichenkette und war zur Laufzeit IMMER undefined.
|
|
const dispositionFilename = node.dispositionParameters?.filename?.toLowerCase() ?? '';
|
|
// Hier dagegen war die Zusicherung schlicht ueberfluessig: imapflow
|
|
// deklariert `parameters?: { [key: string]: string }` (imap-flow.d.ts:438).
|
|
const typeFilename = node.parameters?.name?.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<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;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @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',
|
|
// Repariert in 260921-oxm (Befund B-06 aus 260921-m34): hier stand zuvor
|
|
// `requireTLS`, eine Option, die imapflow 1.4.3 nirgends kennt und still
|
|
// verwirft. Die Bibliothek heisst sie `doSTARTTLS` (imap-flow.d.ts:81).
|
|
// true -> vor der Anmeldung auf TLS hochstufen; scheitert, wenn der
|
|
// Server kein STARTTLS anbietet (imap-flow.js:1183)
|
|
// false -> STARTTLS ausdruecklich aus (imap-flow.js:1210); bei ssl-tls
|
|
// ist das noetig, weil secure=true zusammen mit
|
|
// doSTARTTLS=true ungueltig waere (imap-flow.js:1201)
|
|
doSTARTTLS: 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,
|
|
});
|
|
}
|
|
}
|