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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user