feat(07-02): ImapProvider — IMAP inbox access via imapflow
- 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)
This commit is contained in:
@@ -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<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;
|
||||
|
||||
// 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<InboxEmail[]> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user