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:
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
Reference in New Issue
Block a user