E-Mail Anhang-Downloads stabilisieren

KI-AGENT: Lädt Anhänge nun authentifiziert als Blob herunter und macht die Backend-Zuordnung von Anhängen zur Originalmail robuster, inklusive Positions-Fallback.
This commit is contained in:
2026-05-23 20:42:56 +02:00
parent 358cd906ae
commit 8697810127
2 changed files with 50 additions and 7 deletions

View File

@@ -1,5 +1,5 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { and, desc, eq } from "drizzle-orm" import { and, asc, desc, eq } from "drizzle-orm"
import { ImapFlow } from "imapflow" import { ImapFlow } from "imapflow"
import { simpleParser } from "mailparser" import { simpleParser } from "mailparser"
@@ -625,6 +625,13 @@ export function emailSyncService(server: FastifyInstance) {
const row = rows[0] const row = rows[0]
if (!row) return null if (!row) return null
const messageAttachments = await server.db
.select()
.from(emailAttachments)
.where(eq(emailAttachments.messageId, row.message.id))
.orderBy(asc(emailAttachments.createdAt), asc(emailAttachments.id))
const attachmentIndex = messageAttachments.findIndex((attachment) => attachment.id === attachmentId)
const account = await getAccount(tenantId, userId, row.message.accountId) const account = await getAccount(tenantId, userId, row.message.accountId)
if (!account) { if (!account) {
throw new Error("E-Mail Konto nicht gefunden") throw new Error("E-Mail Konto nicht gefunden")
@@ -644,7 +651,7 @@ export function emailSyncService(server: FastifyInstance) {
if (!fetched.source) break if (!fetched.source) break
const parsed = await simpleParser(fetched.source) const parsed = await simpleParser(fetched.source)
const attachment = parsed.attachments.find((item) => const matchedAttachment = parsed.attachments.find((item) =>
(row.attachment.checksum && item.checksum === row.attachment.checksum) (row.attachment.checksum && item.checksum === row.attachment.checksum)
|| ( || (
item.filename === row.attachment.filename item.filename === row.attachment.filename
@@ -652,6 +659,9 @@ export function emailSyncService(server: FastifyInstance) {
&& Number(item.size || 0) === Number(row.attachment.size || 0) && 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 (!attachment) break

View File

@@ -55,7 +55,6 @@ type EmailMessage = {
} }
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const config = useRuntimeConfig()
const toast = useToast() const toast = useToast()
const accounts = ref<EmailAccount[]>([]) const accounts = ref<EmailAccount[]>([])
@@ -454,8 +453,38 @@ async function moveSelectedMessage() {
} }
} }
function downloadAttachment(attachmentId: string) { async function downloadAttachment(attachment: NonNullable<EmailMessage["attachments"]>[number]) {
window.open(`${config.public.apiBase}/api/email/attachments/${attachmentId}/download`, "_blank") actionLoading.value = `attachment-${attachment.id}`
try {
const response = await $fetch.raw(`/api/email/attachments/${attachment.id}/download`, {
responseType: "blob",
credentials: "include",
headers: {
...(useCookie("token").value ? { Authorization: `Bearer ${useCookie("token").value}` } : {}),
},
})
const blob = response._data as Blob
const disposition = response.headers.get("content-disposition") || ""
const dispositionFilename = disposition.match(/filename="([^"]+)"/)?.[1]
const filename = dispositionFilename || attachment.filename || "anhang"
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (err: any) {
toast.add({
title: "Download fehlgeschlagen",
description: err?.data?.error || err?.message || "Der Anhang konnte nicht geladen werden.",
color: "error",
})
} finally {
actionLoading.value = ""
}
} }
async function setMessageSeen(messageId: string, seen: boolean) { async function setMessageSeen(messageId: string, seen: boolean) {
@@ -814,12 +843,16 @@ onMounted(loadAccounts)
v-for="attachment in selectedMessage.attachments" v-for="attachment in selectedMessage.attachments"
:key="attachment.id" :key="attachment.id"
class="flex items-center gap-2 rounded-md border border-(--ui-border) px-3 py-2 text-left text-sm hover:bg-(--ui-bg-muted)" class="flex items-center gap-2 rounded-md border border-(--ui-border) px-3 py-2 text-left text-sm hover:bg-(--ui-bg-muted)"
@click="downloadAttachment(attachment.id)" @click="downloadAttachment(attachment)"
> >
<UIcon name="i-heroicons-paper-clip" class="size-4 text-dimmed" /> <UIcon name="i-heroicons-paper-clip" class="size-4 text-dimmed" />
<span>{{ attachment.filename || 'Anhang' }}</span> <span>{{ attachment.filename || 'Anhang' }}</span>
<span class="text-xs text-dimmed">{{ formatAttachmentSize(attachment.size) }}</span> <span class="text-xs text-dimmed">{{ formatAttachmentSize(attachment.size) }}</span>
<UIcon name="i-heroicons-arrow-down-tray" class="size-4 text-dimmed" /> <UIcon
:name="actionLoading === `attachment-${attachment.id}` ? 'i-heroicons-arrow-path' : 'i-heroicons-arrow-down-tray'"
class="size-4 text-dimmed"
:class="actionLoading === `attachment-${attachment.id}` ? 'animate-spin' : ''"
/>
</button> </button>
</div> </div>
</div> </div>