From 7239ad92e4f29bf63c989ab524c157c084d14138 Mon Sep 17 00:00:00 2001
From: florianfederspiel
Date: Sat, 23 May 2026 20:13:35 +0200
Subject: [PATCH] E-Mail Lesestatus und Ordnerhierarchie synchronisieren
KI-AGENT: Synchronisiert Gelesen/Ungelesen mit IMAP, gleicht vorhandene Nachrichten-Flags beim Sync ab und zeigt verschachtelte IMAP-Ordner unter ihren Elternordnern an.
---
.../src/modules/email/email.sync.service.ts | 95 +++++++++++
backend/src/routes/emailAsUser.ts | 24 +++
frontend/pages/email/index.vue | 154 +++++++++++++++---
3 files changed, 254 insertions(+), 19 deletions(-)
diff --git a/backend/src/modules/email/email.sync.service.ts b/backend/src/modules/email/email.sync.service.ts
index aa4b4a2..e3223bc 100644
--- a/backend/src/modules/email/email.sync.service.ts
+++ b/backend/src/modules/email/email.sync.service.ts
@@ -301,6 +301,25 @@ export function emailSyncService(server: FastifyInstance) {
return saved
}
+ const updateCachedMessageFlags = async (
+ mailboxId: string,
+ uid: number,
+ flags: string[],
+ ) => {
+ await server.db
+ .update(emailMessages)
+ .set({
+ flags,
+ seen: flags.includes("\\Seen"),
+ flagged: flags.includes("\\Flagged"),
+ updatedAt: new Date(),
+ })
+ .where(and(
+ eq(emailMessages.mailboxId, mailboxId),
+ eq(emailMessages.uid, uid),
+ ))
+ }
+
const syncMailboxMessages = async (
account: MailAccountConnection,
client: ImapFlow,
@@ -323,9 +342,23 @@ export function emailSyncService(server: FastifyInstance) {
const newUids = allUids
.filter((uid: number) => !state?.highestUid || uid > state.highestUid)
.slice(-limit)
+ const flagSyncUids = allUids.slice(-limit)
highestUid = Math.max(state?.highestUid || 0, ...newUids, 0)
+ if (flagSyncUids.length) {
+ for await (const message of client.fetch(flagSyncUids, {
+ uid: true,
+ flags: true,
+ }, { uid: true })) {
+ await updateCachedMessageFlags(
+ mailbox.id,
+ Number(message.uid),
+ flagsFromMessage(message.flags),
+ )
+ }
+ }
+
if (newUids.length) {
for await (const message of client.fetch(newUids, {
uid: true,
@@ -385,6 +418,67 @@ export function emailSyncService(server: FastifyInstance) {
}
}
+ const setMessageSeen = async (
+ tenantId: number,
+ userId: string,
+ messageId: string,
+ seen: boolean,
+ ) => {
+ const rows = await server.db
+ .select()
+ .from(emailMessages)
+ .where(and(
+ eq(emailMessages.tenantId, tenantId),
+ eq(emailMessages.userId, userId),
+ eq(emailMessages.id, messageId),
+ ))
+ .limit(1)
+
+ const message = rows[0]
+ 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)
+ if (seen) {
+ await client.messageFlagsAdd(message.uid, ["\\Seen"], { uid: true })
+ } else {
+ await client.messageFlagsRemove(message.uid, ["\\Seen"], { uid: true })
+ }
+ } finally {
+ lock.release()
+ }
+ } finally {
+ await client.logout().catch(() => client.close())
+ }
+
+ const currentFlags = Array.isArray(message.flags) ? message.flags : []
+ const nextFlags = seen
+ ? Array.from(new Set([...currentFlags, "\\Seen"]))
+ : currentFlags.filter((flag) => flag !== "\\Seen")
+
+ const [updated] = await server.db
+ .update(emailMessages)
+ .set({
+ flags: nextFlags,
+ seen,
+ updatedAt: new Date(),
+ })
+ .where(eq(emailMessages.id, messageId))
+ .returning()
+
+ return updated
+ }
+
const listMailboxes = async (tenantId: number, userId: string, accountId: string) => {
return await server.db
.select()
@@ -451,5 +545,6 @@ export function emailSyncService(server: FastifyInstance) {
listMailboxes,
listMessages,
getMessage,
+ setMessageSeen,
}
}
diff --git a/backend/src/routes/emailAsUser.ts b/backend/src/routes/emailAsUser.ts
index de6c29b..c11a3e2 100644
--- a/backend/src/routes/emailAsUser.ts
+++ b/backend/src/routes/emailAsUser.ts
@@ -369,4 +369,28 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
}
})
+ server.post("/email/messages/:id/read", 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 { seen?: boolean }
+ const message = await emailSync.setMessageSeen(
+ req.user.tenant_id,
+ req.user.user_id,
+ id,
+ body.seen !== false,
+ )
+
+ if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
+
+ return reply.send({ success: true, message })
+ } catch (err: any) {
+ req.log.error(err)
+ return reply.code(500).send({ error: err.message || "Lesestatus konnte nicht synchronisiert werden" })
+ }
+ })
+
}
diff --git a/frontend/pages/email/index.vue b/frontend/pages/email/index.vue
index 9a51240..ebe4f9f 100644
--- a/frontend/pages/email/index.vue
+++ b/frontend/pages/email/index.vue
@@ -13,10 +13,16 @@ type EmailMailbox = {
id: string
path: string
name: string
+ delimiter?: string | null
specialUse?: string | null
unseen?: number
}
+type EmailMailboxNode = {
+ mailbox: EmailMailbox
+ children: EmailMailboxNode[]
+}
+
type EmailAddress = {
name?: string | null
address?: string | null
@@ -72,20 +78,80 @@ const selectedMailbox = computed(() =>
mailboxes.value.find((mailbox) => mailbox.path === selectedMailboxPath.value) || null
)
-const sortedMailboxes = computed(() => {
- const priority = (mailbox: EmailMailbox) => {
- if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return 0
- if (mailbox.specialUse === "\\Sent") return 1
- if (mailbox.specialUse === "\\Drafts") return 2
- if (mailbox.specialUse === "\\Archive") return 3
- if (mailbox.specialUse === "\\Junk") return 4
- if (mailbox.specialUse === "\\Trash") return 5
- return 9
+const mailboxPriority = (mailbox: EmailMailbox) => {
+ if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return 0
+ if (mailbox.specialUse === "\\Sent") return 1
+ if (mailbox.specialUse === "\\Drafts") return 2
+ if (mailbox.specialUse === "\\Archive") return 3
+ if (mailbox.specialUse === "\\Junk") return 4
+ if (mailbox.specialUse === "\\Trash") return 5
+ return 9
+}
+
+const mailboxDelimiter = (mailbox: EmailMailbox) => {
+ if (mailbox.delimiter) return mailbox.delimiter
+ if (mailbox.path.includes("/")) return "/"
+ if (mailbox.path.includes(".")) return "."
+ return "/"
+}
+
+const parentMailboxPath = (mailbox: EmailMailbox, mailboxPaths: Set) => {
+ const delimiter = mailboxDelimiter(mailbox)
+ const parts = mailbox.path.split(delimiter)
+
+ while (parts.length > 1) {
+ parts.pop()
+ const candidate = parts.join(delimiter)
+ if (mailboxPaths.has(candidate)) return candidate
}
- return [...mailboxes.value].sort((first, second) =>
- priority(first) - priority(second) || first.name.localeCompare(second.name)
+ return null
+}
+
+const sortMailboxNodes = (nodes: EmailMailboxNode[]) => {
+ nodes.sort((first, second) =>
+ mailboxPriority(first.mailbox) - mailboxPriority(second.mailbox)
+ || mailboxLabel(first.mailbox).localeCompare(mailboxLabel(second.mailbox))
)
+ nodes.forEach((node) => sortMailboxNodes(node.children))
+ return nodes
+}
+
+const mailboxTree = computed(() => {
+ const mailboxPaths = new Set(mailboxes.value.map((mailbox) => mailbox.path))
+ const nodes = new Map()
+ const roots: EmailMailboxNode[] = []
+
+ mailboxes.value.forEach((mailbox) => {
+ nodes.set(mailbox.path, { mailbox, children: [] })
+ })
+
+ mailboxes.value.forEach((mailbox) => {
+ const node = nodes.get(mailbox.path)
+ if (!node) return
+
+ const parentPath = parentMailboxPath(mailbox, mailboxPaths)
+ const parent = parentPath ? nodes.get(parentPath) : null
+
+ if (parent) {
+ parent.children.push(node)
+ } else {
+ roots.push(node)
+ }
+ })
+
+ return sortMailboxNodes(roots)
+})
+
+const mailboxRows = computed(() => {
+ const rows: Array<{ mailbox: EmailMailbox; depth: number }> = []
+ const append = (node: EmailMailboxNode, depth: number) => {
+ rows.push({ mailbox: node.mailbox, depth })
+ node.children.forEach((child) => append(child, depth + 1))
+ }
+
+ mailboxTree.value.forEach((node) => append(node, 0))
+ return rows
})
const filteredMessages = computed(() => {
@@ -227,11 +293,46 @@ async function selectMessage(message: EmailMessage) {
loadingMessage.value = true
try {
selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
+ if (!message.seen) {
+ await setMessageSeen(message.id, true)
+ }
} finally {
loadingMessage.value = false
}
}
+async function setMessageSeen(messageId: string, seen: boolean) {
+ const previousMessage = messages.value.find((message) => message.id === messageId)
+
+ const res = await $api(`/api/email/messages/${messageId}/read`, {
+ method: "POST",
+ body: { seen },
+ })
+
+ messages.value = messages.value.map((message) =>
+ message.id === messageId ? { ...message, seen } : message
+ )
+
+ if (selectedMessage.value?.id === messageId) {
+ selectedMessage.value = {
+ ...selectedMessage.value,
+ ...(res.message || {}),
+ seen,
+ }
+ }
+
+ if (previousMessage && previousMessage.seen !== seen) {
+ mailboxes.value = mailboxes.value.map((mailbox) => {
+ if (mailbox.path !== previousMessage.mailboxPath) return mailbox
+ const delta = seen ? -1 : 1
+ return {
+ ...mailbox,
+ unseen: Math.max(0, Number(mailbox.unseen || 0) + delta),
+ }
+ })
+ }
+}
+
async function syncAccount() {
if (!selectedAccountId.value) return
@@ -358,21 +459,27 @@ onMounted(loadAccounts)
@@ -451,6 +558,15 @@ onMounted(loadAccounts)
+
+ {{ selectedMessage.seen ? 'Ungelesen' : 'Gelesen' }}
+