diff --git a/backend/src/modules/email/email.sync.service.ts b/backend/src/modules/email/email.sync.service.ts index e1fa068..a8668b0 100644 --- a/backend/src/modules/email/email.sync.service.ts +++ b/backend/src/modules/email/email.sync.service.ts @@ -1,5 +1,5 @@ import { FastifyInstance } from "fastify" -import { and, desc, eq } from "drizzle-orm" +import { and, asc, desc, eq } from "drizzle-orm" import { ImapFlow } from "imapflow" import { simpleParser } from "mailparser" @@ -625,6 +625,13 @@ export function emailSyncService(server: FastifyInstance) { const row = rows[0] 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) if (!account) { throw new Error("E-Mail Konto nicht gefunden") @@ -644,7 +651,7 @@ export function emailSyncService(server: FastifyInstance) { if (!fetched.source) break 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) || ( item.filename === row.attachment.filename @@ -652,6 +659,9 @@ export function emailSyncService(server: FastifyInstance) { && 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 diff --git a/frontend/pages/email/index.vue b/frontend/pages/email/index.vue index a6fea4d..d943afb 100644 --- a/frontend/pages/email/index.vue +++ b/frontend/pages/email/index.vue @@ -55,7 +55,6 @@ type EmailMessage = { } const { $api } = useNuxtApp() -const config = useRuntimeConfig() const toast = useToast() const accounts = ref([]) @@ -454,8 +453,38 @@ async function moveSelectedMessage() { } } -function downloadAttachment(attachmentId: string) { - window.open(`${config.public.apiBase}/api/email/attachments/${attachmentId}/download`, "_blank") +async function downloadAttachment(attachment: NonNullable[number]) { + 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) { @@ -814,12 +843,16 @@ onMounted(loadAccounts) v-for="attachment in selectedMessage.attachments" :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)" - @click="downloadAttachment(attachment.id)" + @click="downloadAttachment(attachment)" > {{ attachment.filename || 'Anhang' }} {{ formatAttachmentSize(attachment.size) }} - +