diff --git a/backend/src/modules/email/email.sync.service.ts b/backend/src/modules/email/email.sync.service.ts index 2172fb0..aba34e9 100644 --- a/backend/src/modules/email/email.sync.service.ts +++ b/backend/src/modules/email/email.sync.service.ts @@ -49,6 +49,84 @@ const previewText = (text?: string | false | null) => { return text.replace(/\s+/g, " ").trim().slice(0, 240) || null } +const attachmentFilename = (node: any) => + node?.dispositionParameters?.filename + || node?.parameters?.name + || null + +const normalizeContentId = (value?: string | null) => + value ? value.replace(/^<|>$/g, "") : null + +const collectAttachmentParts = (node: any, parts: any[] = []) => { + if (!node) return parts + + if (node.part && !node.childNodes?.length) { + const filename = attachmentFilename(node) + const disposition = String(node.disposition || "").toLowerCase() + + if (filename || node.id || ["attachment", "inline"].includes(disposition)) { + parts.push(node) + } + } + + for (const child of node.childNodes || []) { + collectAttachmentParts(child, parts) + } + + return parts +} + +const findAttachmentPart = (bodyStructure: any, attachment: any) => { + const parts = collectAttachmentParts(bodyStructure) + if (!parts.length) return null + + const scored = parts + .map((part) => { + let score = 0 + if (attachmentFilename(part) && attachmentFilename(part) === attachment.filename) score += 4 + if (part.type && part.type === attachment.contentType) score += 3 + if (Number(part.size || 0) === Number(attachment.size || 0)) score += 2 + if ( + normalizeContentId(part.id) + && normalizeContentId(part.id) === normalizeContentId(attachment.contentId) + ) { + score += 3 + } + return { part, score } + }) + .sort((a, b) => b.score - a.score) + + if (scored[0]?.score > 0) return scored[0].part + return parts.length === 1 ? parts[0] : null +} + +const streamToBuffer = async (stream: any, timeoutMs = 45_000) => { + const chunks: Buffer[] = [] + let timeout: NodeJS.Timeout | null = null + + try { + await Promise.race([ + (async () => { + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + })(), + new Promise((_, reject) => { + timeout = setTimeout(() => { + if (typeof stream.destroy === "function") { + stream.destroy() + } + reject(new Error("Anhang-Download hat zu lange gedauert")) + }, timeoutMs) + }), + ]) + } finally { + if (timeout) clearTimeout(timeout) + } + + return Buffer.concat(chunks) +} + const flagsFromMessage = (flags: Set | string[] | undefined) => { if (!flags) return [] return Array.isArray(flags) ? flags : Array.from(flags) @@ -645,34 +723,62 @@ export function emailSyncService(server: FastifyInstance) { try { await client.mailboxOpen(row.message.mailboxPath) - for await (const fetched of client.fetch([row.message.uid], { - source: true, - }, { uid: true })) { - if (!fetched.source) break + const structureMessage = await client.fetchOne(String(row.message.uid), { + uid: true, + bodyStructure: true, + }, { uid: true }) + const attachmentPart = structureMessage + ? findAttachmentPart(structureMessage.bodyStructure, row.attachment) + : null - const parsed = await simpleParser(fetched.source) - const matchedAttachment = parsed.attachments.find((item) => - (row.attachment.checksum && item.checksum === row.attachment.checksum) - || ( - item.filename === row.attachment.filename - && item.contentType === row.attachment.contentType - && Number(item.size || 0) === Number(row.attachment.size || 0) - ) - ) - const attachment = matchedAttachment - || parsed.attachments[attachmentIndex] - || (parsed.attachments.length === 1 ? parsed.attachments[0] : null) + if (attachmentPart?.part) { + const downloaded = await client.download(String(row.message.uid), attachmentPart.part, { uid: true }) + const content = await streamToBuffer(downloaded.content) - if (!attachment) break - - return { - filename: attachment.filename || row.attachment.filename || "anhang", - contentType: attachment.contentType || row.attachment.contentType || "application/octet-stream", - content: Buffer.isBuffer(attachment.content) - ? attachment.content - : Buffer.from(attachment.content), + if (content.length) { + return { + filename: downloaded.meta?.filename + || attachmentFilename(attachmentPart) + || row.attachment.filename + || "anhang", + contentType: downloaded.meta?.contentType + || attachmentPart.type + || row.attachment.contentType + || "application/octet-stream", + content, + } } } + + const fetched = await client.fetchOne(String(row.message.uid), { + source: true, + }, { uid: true }) + if (!fetched || !fetched.source) return null + + const parsed = await simpleParser(fetched.source) + const matchedAttachment = parsed.attachments.find((item) => + (row.attachment.checksum && item.checksum === row.attachment.checksum) + || ( + item.filename === row.attachment.filename + && item.contentType === row.attachment.contentType + && Number(item.size || 0) === Number(row.attachment.size || 0) + ) + ) + const attachment = matchedAttachment + || parsed.attachments[attachmentIndex] + || (parsed.attachments.length === 1 ? parsed.attachments[0] : null) + + if (!attachment) { + return null + } + + return { + filename: attachment.filename || row.attachment.filename || "anhang", + contentType: attachment.contentType || row.attachment.contentType || "application/octet-stream", + content: Buffer.isBuffer(attachment.content) + ? attachment.content + : Buffer.from(attachment.content), + } } finally { lock.release() } diff --git a/frontend/pages/email/index.vue b/frontend/pages/email/index.vue index 635cf5b..7d8c1d9 100644 --- a/frontend/pages/email/index.vue +++ b/frontend/pages/email/index.vue @@ -458,6 +458,7 @@ async function downloadAttachment(attachment: NonNullable