From 358cd906ae4969881a3f2ab8a68a96f1ef660607 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Sat, 23 May 2026 20:38:01 +0200 Subject: [PATCH] =?UTF-8?q?E-Mail=20Aktionen=20und=20Anhang-Download=20erg?= =?UTF-8?q?=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KI-AGENT: Ergänzt IMAP-basierte Aktionen zum Verschieben, Archivieren und Löschen von E-Mails sowie den Download von Anhängen aus der Originalmail. Die E-Mail Oberfläche bietet dafür Zielordner-Auswahl, Aktionsbuttons und Anhang-Downloads. --- .../src/modules/email/email.sync.service.ts | 196 ++++++++++++++++++ backend/src/routes/emailAsUser.ts | 82 ++++++++ frontend/pages/email/index.vue | 138 +++++++++++- 3 files changed, 413 insertions(+), 3 deletions(-) diff --git a/backend/src/modules/email/email.sync.service.ts b/backend/src/modules/email/email.sync.service.ts index e3223bc..e1fa068 100644 --- a/backend/src/modules/email/email.sync.service.ts +++ b/backend/src/modules/email/email.sync.service.ts @@ -479,6 +479,198 @@ export function emailSyncService(server: FastifyInstance) { return updated } + const loadMessageForAction = async (tenantId: number, userId: string, messageId: string) => { + const rows = await server.db + .select() + .from(emailMessages) + .where(and( + eq(emailMessages.tenantId, tenantId), + eq(emailMessages.userId, userId), + eq(emailMessages.id, messageId), + )) + .limit(1) + + return rows[0] || null + } + + const deleteCachedMessage = async (messageId: string) => { + await server.db + .delete(emailMessages) + .where(eq(emailMessages.id, messageId)) + } + + const moveMessage = async ( + tenantId: number, + userId: string, + messageId: string, + destinationMailboxPath: string, + ) => { + const message = await loadMessageForAction(tenantId, userId, messageId) + if (!message) return null + + if (message.mailboxPath === destinationMailboxPath) { + return message + } + + const account = await getAccount(tenantId, userId, message.accountId) + if (!account) { + throw new Error("E-Mail Konto nicht gefunden") + } + + const targetRows = await server.db + .select({ path: emailMailboxes.path }) + .from(emailMailboxes) + .where(and( + eq(emailMailboxes.tenantId, tenantId), + eq(emailMailboxes.userId, userId), + eq(emailMailboxes.accountId, message.accountId), + eq(emailMailboxes.path, destinationMailboxPath), + )) + .limit(1) + + if (!targetRows[0]) { + throw new Error("Zielordner nicht gefunden") + } + + const client = createClient(account) + await client.connect() + + try { + const lock = await client.getMailboxLock(message.mailboxPath) + try { + await client.mailboxOpen(message.mailboxPath) + await client.messageMove(message.uid, destinationMailboxPath, { uid: true }) + } finally { + lock.release() + } + } finally { + await client.logout().catch(() => client.close()) + } + + await deleteCachedMessage(messageId) + return { success: true, destinationMailboxPath } + } + + const archiveMessage = async (tenantId: number, userId: string, messageId: string) => { + const message = await loadMessageForAction(tenantId, userId, messageId) + if (!message) return null + + const mailboxes = await server.db + .select() + .from(emailMailboxes) + .where(and( + eq(emailMailboxes.tenantId, tenantId), + eq(emailMailboxes.userId, userId), + eq(emailMailboxes.accountId, message.accountId), + )) + + const archiveMailbox = mailboxes.find((mailbox) => mailbox.specialUse === "\\Archive") + || mailboxes.find((mailbox) => ["archive", "archiv"].includes(mailbox.name.toLowerCase())) + || mailboxes.find((mailbox) => ["archive", "archiv"].includes(mailbox.path.toLowerCase())) + + if (!archiveMailbox) { + throw new Error("Kein Archivordner gefunden") + } + + return await moveMessage(tenantId, userId, messageId, archiveMailbox.path) + } + + const deleteMessage = async (tenantId: number, userId: string, messageId: string) => { + const message = await loadMessageForAction(tenantId, userId, messageId) + if (!message) return null + + const account = await getAccount(tenantId, userId, message.accountId) + if (!account) { + throw new Error("E-Mail Konto nicht gefunden") + } + + const client = createClient(account) + await client.connect() + + try { + const lock = await client.getMailboxLock(message.mailboxPath) + try { + await client.mailboxOpen(message.mailboxPath) + await client.messageDelete(message.uid, { uid: true }) + } finally { + lock.release() + } + } finally { + await client.logout().catch(() => client.close()) + } + + await deleteCachedMessage(messageId) + return { success: true } + } + + const getAttachmentContent = async ( + tenantId: number, + userId: string, + attachmentId: string, + ) => { + const rows = await server.db + .select({ + attachment: emailAttachments, + message: emailMessages, + }) + .from(emailAttachments) + .innerJoin(emailMessages, eq(emailMessages.id, emailAttachments.messageId)) + .where(and( + eq(emailAttachments.id, attachmentId), + eq(emailMessages.tenantId, tenantId), + eq(emailMessages.userId, userId), + )) + .limit(1) + + const row = rows[0] + if (!row) return null + + const account = await getAccount(tenantId, userId, row.message.accountId) + if (!account) { + throw new Error("E-Mail Konto nicht gefunden") + } + + const client = createClient(account) + await client.connect() + + try { + const lock = await client.getMailboxLock(row.message.mailboxPath) + 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 parsed = await simpleParser(fetched.source) + const attachment = 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) + ) + ) + + if (!attachment) break + + return { + filename: attachment.filename || row.attachment.filename || "anhang", + contentType: attachment.contentType || row.attachment.contentType || "application/octet-stream", + content: attachment.content, + } + } + } finally { + lock.release() + } + } finally { + await client.logout().catch(() => client.close()) + } + + return null + } + const listMailboxes = async (tenantId: number, userId: string, accountId: string) => { return await server.db .select() @@ -546,5 +738,9 @@ export function emailSyncService(server: FastifyInstance) { listMessages, getMessage, setMessageSeen, + moveMessage, + archiveMessage, + deleteMessage, + getAttachmentContent, } } diff --git a/backend/src/routes/emailAsUser.ts b/backend/src/routes/emailAsUser.ts index c11a3e2..7fd8d1e 100644 --- a/backend/src/routes/emailAsUser.ts +++ b/backend/src/routes/emailAsUser.ts @@ -393,4 +393,86 @@ export default async function emailAsUserRoutes(server: FastifyInstance) { } }) + server.post("/email/messages/:id/move", async (req, reply) => { + try { + if (!req.user?.tenant_id) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const { id } = req.params as { id: string } + const body = (req.body || {}) as { mailbox?: string } + + if (!body.mailbox) { + return reply.code(400).send({ error: "Zielordner fehlt" }) + } + + const result = await emailSync.moveMessage( + req.user.tenant_id, + req.user.user_id, + id, + body.mailbox, + ) + + if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" }) + return reply.send({ success: true, ...result }) + } catch (err: any) { + req.log.error(err) + return reply.code(500).send({ error: err.message || "E-Mail konnte nicht verschoben werden" }) + } + }) + + server.post("/email/messages/:id/archive", async (req, reply) => { + try { + if (!req.user?.tenant_id) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const { id } = req.params as { id: string } + const result = await emailSync.archiveMessage(req.user.tenant_id, req.user.user_id, id) + + if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" }) + return reply.send({ success: true, ...result }) + } catch (err: any) { + req.log.error(err) + return reply.code(500).send({ error: err.message || "E-Mail konnte nicht archiviert werden" }) + } + }) + + server.delete("/email/messages/:id", async (req, reply) => { + try { + if (!req.user?.tenant_id) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const { id } = req.params as { id: string } + const result = await emailSync.deleteMessage(req.user.tenant_id, req.user.user_id, id) + + if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" }) + return reply.send({ success: true }) + } catch (err: any) { + req.log.error(err) + return reply.code(500).send({ error: err.message || "E-Mail konnte nicht gelöscht werden" }) + } + }) + + server.get("/email/attachments/:id/download", async (req, reply) => { + try { + if (!req.user?.tenant_id) { + return reply.code(400).send({ error: "No tenant selected" }) + } + + const { id } = req.params as { id: string } + const attachment = await emailSync.getAttachmentContent(req.user.tenant_id, req.user.user_id, id) + + if (!attachment) return reply.code(404).send({ error: "Anhang nicht gefunden" }) + + reply.header("Content-Type", attachment.contentType) + reply.header("Content-Disposition", `attachment; filename="${attachment.filename.replace(/"/g, "")}"`) + return reply.send(attachment.content) + } catch (err: any) { + req.log.error(err) + return reply.code(500).send({ error: err.message || "Anhang konnte nicht geladen werden" }) + } + }) + } diff --git a/frontend/pages/email/index.vue b/frontend/pages/email/index.vue index 496ae0e..a6fea4d 100644 --- a/frontend/pages/email/index.vue +++ b/frontend/pages/email/index.vue @@ -55,6 +55,7 @@ type EmailMessage = { } const { $api } = useNuxtApp() +const config = useRuntimeConfig() const toast = useToast() const accounts = ref([]) @@ -71,6 +72,8 @@ const loadingMessage = ref(false) const syncing = ref(false) const expandedMailboxPaths = ref([]) const syncedMailboxPaths = ref([]) +const actionLoading = ref("") +const moveTargetMailboxPath = ref("") const selectedAccount = computed(() => accounts.value.find((account) => account.id === selectedAccountId.value) || null @@ -171,6 +174,15 @@ const filteredMessages = computed(() => { ].some((value) => String(value || "").toLowerCase().includes(needle))) }) +const moveMailboxOptions = computed(() => + mailboxes.value + .filter((mailbox) => mailbox.path !== selectedMessage.value?.mailboxPath) + .map((mailbox) => ({ + label: mailboxLabel(mailbox), + value: mailbox.path, + })) +) + const mailboxIcon = (mailbox: EmailMailbox) => { if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return "i-heroicons-inbox" if (mailbox.specialUse === "\\Sent") return "i-heroicons-paper-airplane" @@ -354,6 +366,7 @@ async function selectMessage(message: EmailMessage) { loadingMessage.value = true try { selectedMessage.value = await $api(`/api/email/messages/${message.id}`) + moveTargetMailboxPath.value = "" if (!message.seen) { await setMessageSeen(message.id, true) } @@ -362,6 +375,89 @@ async function selectMessage(message: EmailMessage) { } } +function removeMessageFromCurrentList(messageId: string) { + const currentIndex = messages.value.findIndex((message) => message.id === messageId) + messages.value = messages.value.filter((message) => message.id !== messageId) + + const nextMessage = messages.value[currentIndex] || messages.value[currentIndex - 1] || null + selectedMessage.value = null + + if (nextMessage) { + selectMessage(nextMessage) + } +} + +async function archiveSelectedMessage() { + if (!selectedMessage.value) return + + const messageId = selectedMessage.value.id + actionLoading.value = "archive" + + try { + await $api(`/api/email/messages/${messageId}/archive`, { method: "POST" }) + removeMessageFromCurrentList(messageId) + toast.add({ title: "E-Mail archiviert", color: "success" }) + } catch (err: any) { + toast.add({ + title: "Archivieren fehlgeschlagen", + description: err?.data?.error || err?.message || "Die E-Mail konnte nicht archiviert werden.", + color: "error", + }) + } finally { + actionLoading.value = "" + } +} + +async function deleteSelectedMessage() { + if (!selectedMessage.value) return + + const messageId = selectedMessage.value.id + actionLoading.value = "delete" + + try { + await $api(`/api/email/messages/${messageId}`, { method: "DELETE" }) + removeMessageFromCurrentList(messageId) + toast.add({ title: "E-Mail gelöscht", color: "success" }) + } catch (err: any) { + toast.add({ + title: "Löschen fehlgeschlagen", + description: err?.data?.error || err?.message || "Die E-Mail konnte nicht gelöscht werden.", + color: "error", + }) + } finally { + actionLoading.value = "" + } +} + +async function moveSelectedMessage() { + if (!selectedMessage.value || !moveTargetMailboxPath.value) return + + const messageId = selectedMessage.value.id + actionLoading.value = "move" + + try { + await $api(`/api/email/messages/${messageId}/move`, { + method: "POST", + body: { mailbox: moveTargetMailboxPath.value }, + }) + removeMessageFromCurrentList(messageId) + toast.add({ title: "E-Mail verschoben", color: "success" }) + moveTargetMailboxPath.value = "" + } catch (err: any) { + toast.add({ + title: "Verschieben fehlgeschlagen", + description: err?.data?.error || err?.message || "Die E-Mail konnte nicht verschoben werden.", + color: "error", + }) + } finally { + actionLoading.value = "" + } +} + +function downloadAttachment(attachmentId: string) { + window.open(`${config.public.apiBase}/api/email/attachments/${attachmentId}/download`, "_blank") +} + async function setMessageSeen(messageId: string, seen: boolean) { const previousMessage = messages.value.find((message) => message.id === messageId) @@ -634,6 +730,40 @@ onMounted(loadAccounts)

+ + + +
-
{{ attachment.filename || 'Anhang' }} {{ formatAttachmentSize(attachment.size) }} -
+ +