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) }}
-
+
+