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