E-Mail Anhänge gezielt per IMAP-Part laden

KI-AGENT: Der Anhang-Download lädt jetzt bevorzugt den passenden MIME-Part über die IMAP-BODYSTRUCTURE und bekommt ein Timeout, damit der Download nicht dauerhaft lädt.
This commit is contained in:
2026-05-23 21:00:28 +02:00
parent d45cefbc20
commit 2c96b9c5a5
2 changed files with 131 additions and 24 deletions

View File

@@ -49,6 +49,84 @@ const previewText = (text?: string | false | null) => {
return text.replace(/\s+/g, " ").trim().slice(0, 240) || 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> | string[] | undefined) => { const flagsFromMessage = (flags: Set<string> | string[] | undefined) => {
if (!flags) return [] if (!flags) return []
return Array.isArray(flags) ? flags : Array.from(flags) return Array.isArray(flags) ? flags : Array.from(flags)
@@ -645,34 +723,62 @@ export function emailSyncService(server: FastifyInstance) {
try { try {
await client.mailboxOpen(row.message.mailboxPath) await client.mailboxOpen(row.message.mailboxPath)
for await (const fetched of client.fetch([row.message.uid], { const structureMessage = await client.fetchOne(String(row.message.uid), {
source: true, uid: true,
}, { uid: true })) { bodyStructure: true,
if (!fetched.source) break }, { uid: true })
const attachmentPart = structureMessage
? findAttachmentPart(structureMessage.bodyStructure, row.attachment)
: null
const parsed = await simpleParser(fetched.source) if (attachmentPart?.part) {
const matchedAttachment = parsed.attachments.find((item) => const downloaded = await client.download(String(row.message.uid), attachmentPart.part, { uid: true })
(row.attachment.checksum && item.checksum === row.attachment.checksum) const content = await streamToBuffer(downloaded.content)
|| (
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) break if (content.length) {
return {
return { filename: downloaded.meta?.filename
filename: attachment.filename || row.attachment.filename || "anhang", || attachmentFilename(attachmentPart)
contentType: attachment.contentType || row.attachment.contentType || "application/octet-stream", || row.attachment.filename
content: Buffer.isBuffer(attachment.content) || "anhang",
? attachment.content contentType: downloaded.meta?.contentType
: Buffer.from(attachment.content), || 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 { } finally {
lock.release() lock.release()
} }

View File

@@ -458,6 +458,7 @@ async function downloadAttachment(attachment: NonNullable<EmailMessage["attachme
try { try {
const response = await $api.raw(`/api/email/attachments/${attachment.id}/download`, { const response = await $api.raw(`/api/email/attachments/${attachment.id}/download`, {
responseType: "arrayBuffer", responseType: "arrayBuffer",
timeout: 60_000,
}) })
const contentType = response.headers.get("content-type") || attachment.contentType || "application/octet-stream" const contentType = response.headers.get("content-type") || attachment.contentType || "application/octet-stream"
const blob = new Blob([response._data as ArrayBuffer], { type: contentType }) const blob = new Blob([response._data as ArrayBuffer], { type: contentType })