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.
This commit is contained in:
2026-05-23 20:13:35 +02:00
parent 347319aee3
commit 7239ad92e4
3 changed files with 254 additions and 19 deletions

View File

@@ -301,6 +301,25 @@ export function emailSyncService(server: FastifyInstance) {
return saved 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 ( const syncMailboxMessages = async (
account: MailAccountConnection, account: MailAccountConnection,
client: ImapFlow, client: ImapFlow,
@@ -323,9 +342,23 @@ export function emailSyncService(server: FastifyInstance) {
const newUids = allUids const newUids = allUids
.filter((uid: number) => !state?.highestUid || uid > state.highestUid) .filter((uid: number) => !state?.highestUid || uid > state.highestUid)
.slice(-limit) .slice(-limit)
const flagSyncUids = allUids.slice(-limit)
highestUid = Math.max(state?.highestUid || 0, ...newUids, 0) 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) { if (newUids.length) {
for await (const message of client.fetch(newUids, { for await (const message of client.fetch(newUids, {
uid: true, 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) => { const listMailboxes = async (tenantId: number, userId: string, accountId: string) => {
return await server.db return await server.db
.select() .select()
@@ -451,5 +545,6 @@ export function emailSyncService(server: FastifyInstance) {
listMailboxes, listMailboxes,
listMessages, listMessages,
getMessage, getMessage,
setMessageSeen,
} }
} }

View File

@@ -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" })
}
})
} }

View File

@@ -13,10 +13,16 @@ type EmailMailbox = {
id: string id: string
path: string path: string
name: string name: string
delimiter?: string | null
specialUse?: string | null specialUse?: string | null
unseen?: number unseen?: number
} }
type EmailMailboxNode = {
mailbox: EmailMailbox
children: EmailMailboxNode[]
}
type EmailAddress = { type EmailAddress = {
name?: string | null name?: string | null
address?: string | null address?: string | null
@@ -72,20 +78,80 @@ const selectedMailbox = computed(() =>
mailboxes.value.find((mailbox) => mailbox.path === selectedMailboxPath.value) || null mailboxes.value.find((mailbox) => mailbox.path === selectedMailboxPath.value) || null
) )
const sortedMailboxes = computed(() => { const mailboxPriority = (mailbox: EmailMailbox) => {
const priority = (mailbox: EmailMailbox) => { if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return 0
if (mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") return 0 if (mailbox.specialUse === "\\Sent") return 1
if (mailbox.specialUse === "\\Sent") return 1 if (mailbox.specialUse === "\\Drafts") return 2
if (mailbox.specialUse === "\\Drafts") return 2 if (mailbox.specialUse === "\\Archive") return 3
if (mailbox.specialUse === "\\Archive") return 3 if (mailbox.specialUse === "\\Junk") return 4
if (mailbox.specialUse === "\\Junk") return 4 if (mailbox.specialUse === "\\Trash") return 5
if (mailbox.specialUse === "\\Trash") return 5 return 9
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<string>) => {
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) => return null
priority(first) - priority(second) || first.name.localeCompare(second.name) }
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<string, EmailMailboxNode>()
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(() => { const filteredMessages = computed(() => {
@@ -227,11 +293,46 @@ async function selectMessage(message: EmailMessage) {
loadingMessage.value = true loadingMessage.value = true
try { try {
selectedMessage.value = await $api(`/api/email/messages/${message.id}`) selectedMessage.value = await $api(`/api/email/messages/${message.id}`)
if (!message.seen) {
await setMessageSeen(message.id, true)
}
} finally { } finally {
loadingMessage.value = false 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() { async function syncAccount() {
if (!selectedAccountId.value) return if (!selectedAccountId.value) return
@@ -358,21 +459,27 @@ onMounted(loadAccounts)
<nav v-else class="p-2"> <nav v-else class="p-2">
<button <button
v-for="mailbox in sortedMailboxes" v-for="row in mailboxRows"
:key="mailbox.id" :key="row.mailbox.id"
class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors" class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors"
:class="selectedMailboxPath === mailbox.path ? 'bg-primary/10 text-primary' : 'hover:bg-(--ui-bg-muted)'" :class="selectedMailboxPath === row.mailbox.path ? 'bg-primary/10 text-primary' : 'hover:bg-(--ui-bg-muted)'"
@click="selectMailbox(mailbox)" :style="{ paddingLeft: `${12 + row.depth * 18}px` }"
@click="selectMailbox(row.mailbox)"
> >
<UIcon :name="mailboxIcon(mailbox)" class="size-4 shrink-0" /> <UIcon
<span class="min-w-0 flex-1 truncate">{{ mailboxLabel(mailbox) }}</span> v-if="row.depth > 0"
name="i-heroicons-chevron-right"
class="size-3 shrink-0 text-dimmed"
/>
<UIcon :name="mailboxIcon(row.mailbox)" class="size-4 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ mailboxLabel(row.mailbox) }}</span>
<UBadge <UBadge
v-if="mailbox.unseen" v-if="row.mailbox.unseen"
size="xs" size="xs"
color="primary" color="primary"
variant="soft" variant="soft"
> >
{{ mailbox.unseen }} {{ row.mailbox.unseen }}
</UBadge> </UBadge>
</button> </button>
</nav> </nav>
@@ -451,6 +558,15 @@ onMounted(loadAccounts)
</p> </p>
</div> </div>
<div class="flex shrink-0 items-center gap-2"> <div class="flex shrink-0 items-center gap-2">
<UButton
:icon="selectedMessage.seen ? 'i-heroicons-envelope' : 'i-heroicons-envelope-open'"
color="neutral"
variant="ghost"
size="sm"
@click="setMessageSeen(selectedMessage.id, !selectedMessage.seen)"
>
{{ selectedMessage.seen ? 'Ungelesen' : 'Gelesen' }}
</UButton>
<UButton <UButton
icon="i-heroicons-arrow-uturn-left" icon="i-heroicons-arrow-uturn-left"
color="neutral" color="neutral"