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

@@ -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<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) =>
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<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(() => {
@@ -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)
<nav v-else class="p-2">
<button
v-for="mailbox in sortedMailboxes"
:key="mailbox.id"
v-for="row in mailboxRows"
: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="selectedMailboxPath === mailbox.path ? 'bg-primary/10 text-primary' : 'hover:bg-(--ui-bg-muted)'"
@click="selectMailbox(mailbox)"
:class="selectedMailboxPath === row.mailbox.path ? 'bg-primary/10 text-primary' : 'hover:bg-(--ui-bg-muted)'"
:style="{ paddingLeft: `${12 + row.depth * 18}px` }"
@click="selectMailbox(row.mailbox)"
>
<UIcon :name="mailboxIcon(mailbox)" class="size-4 shrink-0" />
<span class="min-w-0 flex-1 truncate">{{ mailboxLabel(mailbox) }}</span>
<UIcon
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
v-if="mailbox.unseen"
v-if="row.mailbox.unseen"
size="xs"
color="primary"
variant="soft"
>
{{ mailbox.unseen }}
{{ row.mailbox.unseen }}
</UBadge>
</button>
</nav>
@@ -451,6 +558,15 @@ onMounted(loadAccounts)
</p>
</div>
<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
icon="i-heroicons-arrow-uturn-left"
color="neutral"