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