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