KI-AGENT: Mailantworten und Entitätsverknüpfungen ergänzen

This commit is contained in:
2026-09-07 19:00:08 +02:00
parent 4e4466ff11
commit f3384cfc74
10 changed files with 774 additions and 30 deletions

View File

@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS "email_entity_links" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"message_id" uuid NOT NULL,
"entity_type" text NOT NULL,
"entity_id" bigint NOT NULL,
"linked_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "email_entity_links_tenant_id_tenants_id_fk"
FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id")
ON DELETE cascade ON UPDATE cascade,
CONSTRAINT "email_entity_links_message_id_email_messages_id_fk"
FOREIGN KEY ("message_id") REFERENCES "public"."email_messages"("id")
ON DELETE cascade ON UPDATE cascade,
CONSTRAINT "email_entity_links_linked_by_auth_users_id_fk"
FOREIGN KEY ("linked_by") REFERENCES "public"."auth_users"("id")
ON DELETE set null ON UPDATE cascade
);
CREATE UNIQUE INDEX IF NOT EXISTS "email_entity_links_message_entity_key"
ON "email_entity_links" USING btree ("message_id", "entity_type", "entity_id");
CREATE INDEX IF NOT EXISTS "email_entity_links_entity_idx"
ON "email_entity_links" USING btree ("tenant_id", "entity_type", "entity_id");
CREATE INDEX IF NOT EXISTS "email_entity_links_message_idx"
ON "email_entity_links" USING btree ("message_id");

View File

@@ -442,6 +442,13 @@
"when": 1788202715001, "when": 1788202715001,
"tag": "0065_document_templates", "tag": "0065_document_templates",
"breakpoints": true "breakpoints": true
},
{
"idx": 63,
"version": "7",
"when": 1788799700000,
"tag": "0066_email_entity_links",
"breakpoints": true
} }
] ]
} }

View File

@@ -155,6 +155,39 @@ export const emailAttachments = pgTable(
}), }),
) )
export const emailEntityLinks = pgTable(
"email_entity_links",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
messageId: uuid("message_id")
.notNull()
.references(() => emailMessages.id, { onDelete: "cascade", onUpdate: "cascade" }),
entityType: text("entity_type").notNull(),
entityId: bigint("entity_id", { mode: "number" }).notNull(),
linkedBy: uuid("linked_by")
.references(() => authUsers.id, { onDelete: "set null", onUpdate: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
messageEntityKey: uniqueIndex("email_entity_links_message_entity_key")
.on(table.messageId, table.entityType, table.entityId),
entityIdx: index("email_entity_links_entity_idx")
.on(table.tenantId, table.entityType, table.entityId),
messageIdx: index("email_entity_links_message_idx")
.on(table.messageId),
}),
)
export const emailSyncState = pgTable( export const emailSyncState = pgTable(
"email_sync_state", "email_sync_state",
{ {
@@ -204,5 +237,7 @@ export type EmailMessageBody = typeof emailMessageBodies.$inferSelect
export type NewEmailMessageBody = typeof emailMessageBodies.$inferInsert export type NewEmailMessageBody = typeof emailMessageBodies.$inferInsert
export type EmailAttachment = typeof emailAttachments.$inferSelect export type EmailAttachment = typeof emailAttachments.$inferSelect
export type NewEmailAttachment = typeof emailAttachments.$inferInsert export type NewEmailAttachment = typeof emailAttachments.$inferInsert
export type EmailEntityLink = typeof emailEntityLinks.$inferSelect
export type NewEmailEntityLink = typeof emailEntityLinks.$inferInsert
export type EmailSyncState = typeof emailSyncState.$inferSelect export type EmailSyncState = typeof emailSyncState.$inferSelect
export type NewEmailSyncState = typeof emailSyncState.$inferInsert export type NewEmailSyncState = typeof emailSyncState.$inferInsert

View File

@@ -1,9 +1,17 @@
import nodemailer from "nodemailer" import nodemailer from "nodemailer"
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { and, eq } from "drizzle-orm" import { and, desc, eq } from "drizzle-orm"
import { encrypt, decrypt } from "../utils/crypt" import { encrypt, decrypt } from "../utils/crypt"
import { userCredentials } from "../../db/schema" import {
customers,
emailEntityLinks,
emailMessages,
plants,
projects,
userCredentials,
vendors,
} from "../../db/schema"
import { emailSyncService } from "../modules/email/email.sync.service" import { emailSyncService } from "../modules/email/email.sync.service"
// @ts-ignore // @ts-ignore
@@ -39,6 +47,53 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const bodyValue = (body: any, camelKey: string, snakeKey: string) => body[camelKey] ?? body[snakeKey] const bodyValue = (body: any, camelKey: string, snakeKey: string) => body[camelKey] ?? body[snakeKey]
const entityDefinitions = {
customers: { table: customers, tenantColumn: customers.tenant, labelColumn: customers.name, label: "Kunde" },
vendors: { table: vendors, tenantColumn: vendors.tenant, labelColumn: vendors.name, label: "Lieferant" },
projects: { table: projects, tenantColumn: projects.tenant, labelColumn: projects.name, label: "Projekt" },
plants: { table: plants, tenantColumn: plants.tenant, labelColumn: plants.name, label: "Objekt" },
} as const
type EmailEntityType = keyof typeof entityDefinitions
const getEntityDefinition = (entityType: string) =>
entityDefinitions[entityType as EmailEntityType] || null
const loadEntity = async (tenantId: number, entityType: string, entityId: number) => {
const definition = getEntityDefinition(entityType)
if (!definition) return null
const rows = await server.db
.select({ id: definition.table.id, name: definition.labelColumn })
.from(definition.table)
.where(and(
eq(definition.tenantColumn, tenantId),
eq(definition.table.id, entityId),
))
.limit(1)
return rows[0] ? { ...rows[0], typeLabel: definition.label } : null
}
const listMessageEntityLinks = async (tenantId: number, messageId: string) => {
const links = await server.db
.select()
.from(emailEntityLinks)
.where(and(
eq(emailEntityLinks.tenantId, tenantId),
eq(emailEntityLinks.messageId, messageId),
))
return (await Promise.all(links.map(async (link) => {
const entity = await loadEntity(tenantId, link.entityType, link.entityId)
return entity ? {
...link,
entityName: entity.name,
entityTypeLabel: entity.typeLabel,
} : null
}))).filter(Boolean)
}
const applyDownloadCorsHeaders = (req: any, reply: any) => { const applyDownloadCorsHeaders = (req: any, reply: any) => {
const origin = req.headers.origin const origin = req.headers.origin
if ( if (
@@ -221,6 +276,8 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
html?: string html?: string
attachments?: any attachments?: any
account: string account: string
sourceMessageId?: string
composeMode?: "reply" | "replyAll" | "forward"
} }
// Fetch email credentials // Fetch email credentials
@@ -248,6 +305,35 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
}, },
}) })
const sourceMessage = body.sourceMessageId
? await emailSync.getMessage(req.user.tenant_id, req.user.user_id, body.sourceMessageId)
: null
if (body.sourceMessageId && !sourceMessage) {
return reply.code(404).send({ error: "Ursprüngliche E-Mail nicht gefunden" })
}
const attachments = [...(Array.isArray(body.attachments) ? body.attachments : [])]
if (body.composeMode === "forward" && sourceMessage?.attachments?.length) {
for (const sourceAttachment of sourceMessage.attachments) {
const attachment = await emailSync.getAttachmentContent(
req.user.tenant_id,
req.user.user_id,
sourceAttachment.id,
)
if (!attachment) continue
attachments.push({
filename: attachment.filename,
content: attachment.content,
contentType: attachment.contentType,
contentDisposition: "attachment",
})
}
}
const isReply = body.composeMode === "reply" || body.composeMode === "replyAll"
const message = { const message = {
from: accountData.email, from: accountData.email,
to: body.to, to: body.to,
@@ -256,7 +342,9 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
subject: body.subject, subject: body.subject,
html: body.html, html: body.html,
text: body.text, text: body.text,
attachments: body.attachments, attachments,
inReplyTo: isReply ? sourceMessage?.messageId || undefined : undefined,
references: isReply && sourceMessage?.messageId ? [sourceMessage.messageId] : undefined,
} }
const info = await transporter.sendMail(message) const info = await transporter.sendMail(message)
@@ -381,13 +469,144 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
const message = await emailSync.getMessage(req.user.tenant_id, req.user.user_id, id) const message = await emailSync.getMessage(req.user.tenant_id, req.user.user_id, id)
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" }) if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
return reply.send(message) return reply.send({
...message,
entityLinks: await listMessageEntityLinks(req.user.tenant_id, id),
})
} catch (err: any) { } catch (err: any) {
req.log.error(err) req.log.error(err)
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht geladen werden" }) return reply.code(500).send({ error: err.message || "E-Mail konnte nicht geladen werden" })
} }
}) })
server.post("/email/messages/:id/entity-links", 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 { entityType?: string; entityId?: number | string }
const entityId = Number(body.entityId)
if (!body.entityType || !getEntityDefinition(body.entityType)) {
return reply.code(400).send({ error: "Nicht unterstützter Entitätstyp" })
}
if (!Number.isSafeInteger(entityId) || entityId <= 0) {
return reply.code(400).send({ error: "Ungültige Entitäts-ID" })
}
const message = await emailSync.getMessage(req.user.tenant_id, req.user.user_id, id)
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
const entity = await loadEntity(req.user.tenant_id, body.entityType, entityId)
if (!entity) return reply.code(404).send({ error: "Entität nicht gefunden" })
await server.db
.insert(emailEntityLinks)
.values({
tenantId: req.user.tenant_id,
messageId: id,
entityType: body.entityType,
entityId,
linkedBy: req.user.user_id,
})
.onConflictDoNothing()
return reply.send({
success: true,
entityLinks: await listMessageEntityLinks(req.user.tenant_id, id),
})
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht verknüpft werden" })
}
})
server.delete("/email/messages/:id/entity-links/:entityType/:entityId", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { id, entityType, entityId: rawEntityId } = req.params as {
id: string
entityType: string
entityId: string
}
const entityId = Number(rawEntityId)
const message = await emailSync.getMessage(req.user.tenant_id, req.user.user_id, id)
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
await server.db
.delete(emailEntityLinks)
.where(and(
eq(emailEntityLinks.tenantId, req.user.tenant_id),
eq(emailEntityLinks.messageId, id),
eq(emailEntityLinks.entityType, entityType),
eq(emailEntityLinks.entityId, entityId),
))
return reply.send({
success: true,
entityLinks: await listMessageEntityLinks(req.user.tenant_id, id),
})
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "Verknüpfung konnte nicht entfernt werden" })
}
})
server.get("/email/entity-links/:entityType/:entityId", async (req, reply) => {
try {
if (!req.user?.tenant_id) {
return reply.code(400).send({ error: "No tenant selected" })
}
const { entityType, entityId: rawEntityId } = req.params as {
entityType: string
entityId: string
}
const entityId = Number(rawEntityId)
if (!getEntityDefinition(entityType)) {
return reply.code(400).send({ error: "Nicht unterstützter Entitätstyp" })
}
if (!Number.isSafeInteger(entityId) || entityId <= 0) {
return reply.code(400).send({ error: "Ungültige Entitäts-ID" })
}
if (!await loadEntity(req.user.tenant_id, entityType, entityId)) {
return reply.code(404).send({ error: "Entität nicht gefunden" })
}
const rows = await server.db
.select({
message: emailMessages,
linkId: emailEntityLinks.id,
linkedAt: emailEntityLinks.createdAt,
})
.from(emailEntityLinks)
.innerJoin(emailMessages, eq(emailMessages.id, emailEntityLinks.messageId))
.where(and(
eq(emailEntityLinks.tenantId, req.user.tenant_id),
eq(emailEntityLinks.entityType, entityType),
eq(emailEntityLinks.entityId, entityId),
))
.orderBy(desc(emailMessages.receivedAt), desc(emailMessages.sentAt))
return reply.send(rows.map((row) => ({
...row.message,
linkId: row.linkId,
linkedAt: row.linkedAt,
canOpen: row.message.userId === req.user.user_id,
})))
} catch (err: any) {
req.log.error(err)
return reply.code(500).send({ error: err.message || "Verknüpfte E-Mails konnten nicht geladen werden" })
}
})
server.post("/email/messages/:id/read", async (req, reply) => { server.post("/email/messages/:id/read", async (req, reply) => {
try { try {
if (!req.user?.tenant_id) { if (!req.user?.tenant_id) {

View File

@@ -366,6 +366,11 @@ const invitePortalUser = async () => {
v-else-if="tab.label === 'Zeiten'" v-else-if="tab.label === 'Zeiten'"
:platform="platform" :platform="platform"
/> />
<EmailEntityMessages
v-else-if="tab.label === 'E-Mails'"
:entity-type="type"
:entity-id="props.item.id"
/>
<div v-else-if="tab.label === 'Wiki'" class="h-[600px] w-full overflow-hidden"> <div v-else-if="tab.label === 'Wiki'" class="h-[600px] w-full overflow-hidden">
<WikiEntityWidget <WikiEntityWidget
:entity-type="type" :entity-type="type"
@@ -410,6 +415,11 @@ const invitePortalUser = async () => {
@updateNeeded="emit('updateNeeded')" @updateNeeded="emit('updateNeeded')"
:platform="platform" :platform="platform"
/> />
<EmailEntityMessages
v-else-if="sub.label === 'E-Mails'"
:entity-type="type"
:entity-id="props.item.id"
/>
<!--<EntityShowSubPhases <!--<EntityShowSubPhases
:item="props.item" :item="props.item"
:top-level-type="type" :top-level-type="type"

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
type EntityLink = {
id: string
entityType: string
entityId: number
entityName: string
entityTypeLabel: string
}
const props = defineProps<{
messageId: string
entityLinks?: EntityLink[]
}>()
const emit = defineEmits<{
updated: [links: EntityLink[]]
}>()
const toast = useToast()
const links = ref<EntityLink[]>([])
const selectedEntityType = ref("customers")
const selectedEntityId = ref<number | null>(null)
const entityOptions = ref<Array<{ label: string; value: number }>>([])
const loadingOptions = ref(false)
const saving = ref(false)
const entityTypes = [
{ label: "Kunde", value: "customers" },
{ label: "Lieferant", value: "vendors" },
{ label: "Projekt", value: "projects" },
{ label: "Objekt", value: "plants" },
]
watch(() => props.entityLinks, (value) => {
links.value = [...(value || [])]
}, { immediate: true, deep: true })
async function loadEntityOptions() {
loadingOptions.value = true
selectedEntityId.value = null
try {
const rows = await useEntities(selectedEntityType.value).select("*")
entityOptions.value = rows.map((row: any) => {
const number = row.customerNumber || row.vendorNumber || row.projectNumber
return {
label: number ? `${number} · ${row.name}` : row.name,
value: Number(row.id),
}
})
} catch (err: any) {
entityOptions.value = []
toast.add({
title: "Entitäten konnten nicht geladen werden",
description: err?.data?.error || err?.message,
color: "error",
})
} finally {
loadingOptions.value = false
}
}
async function addLink() {
if (!selectedEntityId.value) return
saving.value = true
try {
const response = await useNuxtApp().$api(`/api/email/messages/${props.messageId}/entity-links`, {
method: "POST",
body: {
entityType: selectedEntityType.value,
entityId: selectedEntityId.value,
},
})
links.value = response.entityLinks || []
emit("updated", links.value)
selectedEntityId.value = null
toast.add({ title: "E-Mail verknüpft", color: "success" })
} catch (err: any) {
toast.add({
title: "Verknüpfen fehlgeschlagen",
description: err?.data?.error || err?.message,
color: "error",
})
} finally {
saving.value = false
}
}
async function removeLink(link: EntityLink) {
saving.value = true
try {
const response = await useNuxtApp().$api(
`/api/email/messages/${props.messageId}/entity-links/${link.entityType}/${link.entityId}`,
{ method: "DELETE" },
)
links.value = response.entityLinks || []
emit("updated", links.value)
toast.add({ title: "Verknüpfung entfernt", color: "success" })
} catch (err: any) {
toast.add({
title: "Entfernen fehlgeschlagen",
description: err?.data?.error || err?.message,
color: "error",
})
} finally {
saving.value = false
}
}
watch(selectedEntityType, loadEntityOptions, { immediate: true })
</script>
<template>
<div class="mt-4 rounded-lg border border-(--ui-border) bg-(--ui-bg-muted) p-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium">Verknüpft mit</span>
<UBadge
v-for="link in links"
:key="link.id"
color="primary"
variant="soft"
class="gap-1"
>
{{ link.entityTypeLabel }}: {{ link.entityName }}
<button
type="button"
class="ml-1 rounded hover:text-error"
:disabled="saving"
:aria-label="`Verknüpfung mit ${link.entityName} entfernen`"
@click="removeLink(link)"
>
<UIcon name="i-heroicons-x-mark" class="size-3.5" />
</button>
</UBadge>
<span v-if="!links.length" class="text-sm text-dimmed">Noch keine Zuordnung</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<USelectMenu
v-model="selectedEntityType"
:items="entityTypes"
value-key="value"
label-key="label"
size="sm"
class="w-36"
/>
<USelectMenu
v-model="selectedEntityId"
:items="entityOptions"
value-key="value"
label-key="label"
size="sm"
class="min-w-64 flex-1"
placeholder="Entität auswählen"
:loading="loadingOptions"
/>
<UButton
icon="i-heroicons-link"
size="sm"
:loading="saving"
:disabled="!selectedEntityId"
@click="addLink"
>
Verknüpfen
</UButton>
</div>
</div>
</template>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
type EmailAddress = {
name?: string | null
address?: string | null
}
type LinkedMessage = {
id: string
accountId: string
mailboxPath: string
subject?: string | null
from?: EmailAddress[] | null
to?: EmailAddress[] | null
preview?: string | null
receivedAt?: string | null
sentAt?: string | null
linkedAt: string
hasAttachments: boolean
canOpen: boolean
}
const props = defineProps<{
entityType: string
entityId: string | number
}>()
const messages = ref<LinkedMessage[]>([])
const loading = ref(true)
const errorMessage = ref("")
const formatAddress = (addresses?: EmailAddress[] | null) => {
const address = addresses?.[0]
if (!address) return "Unbekannt"
return address.name || address.address || "Unbekannt"
}
const formatDate = (value?: string | null) => {
if (!value) return ""
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value))
}
const openMessage = (message: LinkedMessage) => navigateTo({
path: "/email",
query: {
account: message.accountId,
mailbox: message.mailboxPath,
message: message.id,
},
})
async function loadMessages() {
loading.value = true
errorMessage.value = ""
try {
messages.value = await useNuxtApp().$api(
`/api/email/entity-links/${props.entityType}/${props.entityId}`,
)
} catch (err: any) {
errorMessage.value = err?.data?.error || err?.message || "Verknüpfte E-Mails konnten nicht geladen werden."
} finally {
loading.value = false
}
}
watch(() => [props.entityType, props.entityId], loadMessages, { immediate: true })
</script>
<template>
<div class="space-y-3">
<div v-if="loading" class="space-y-3">
<USkeleton v-for="index in 4" :key="index" class="h-28" />
</div>
<UAlert
v-else-if="errorMessage"
color="error"
icon="i-heroicons-exclamation-triangle"
title="E-Mails konnten nicht geladen werden"
:description="errorMessage"
/>
<TableEmptyState
v-else-if="!messages.length"
label="Noch keine E-Mails mit dieser Entität verknüpft"
/>
<article
v-for="message in messages"
v-else
:key="message.id"
class="rounded-lg border border-(--ui-border) bg-(--ui-bg) p-4"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h3 class="truncate font-medium text-highlighted">
{{ message.subject || '(kein Betreff)' }}
</h3>
<p class="mt-1 text-sm text-dimmed">
Von {{ formatAddress(message.from) }} · {{ formatDate(message.receivedAt || message.sentAt) }}
</p>
</div>
<UButton
v-if="message.canOpen"
icon="i-heroicons-arrow-top-right-on-square"
color="neutral"
variant="soft"
size="sm"
@click="openMessage(message)"
>
Im Postfach öffnen
</UButton>
</div>
<p class="mt-3 line-clamp-2 text-sm text-muted">
{{ message.preview || 'Keine Vorschau verfügbar' }}
</p>
<div class="mt-3 flex items-center gap-3 text-xs text-dimmed">
<span>Verknüpft am {{ formatDate(message.linkedAt) }}</span>
<span v-if="message.hasAttachments" class="flex items-center gap-1">
<UIcon name="i-heroicons-paper-clip" class="size-3.5" />
Anhänge
</span>
</div>
</article>
</div>
</template>

View File

@@ -52,9 +52,17 @@ type EmailMessage = {
contentType?: string | null contentType?: string | null
size?: number | null size?: number | null
}> }>
entityLinks?: Array<{
id: string
entityType: string
entityId: number
entityName: string
entityTypeLabel: string
}>
} }
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const route = useRoute()
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const toast = useToast() const toast = useToast()
@@ -74,6 +82,7 @@ const expandedMailboxPaths = ref<string[]>([])
const syncedMailboxPaths = ref<string[]>([]) const syncedMailboxPaths = ref<string[]>([])
const actionLoading = ref("") const actionLoading = ref("")
const moveTargetMailboxPath = ref("") const moveTargetMailboxPath = ref("")
let deepLinkApplied = false
const selectedAccount = computed(() => const selectedAccount = computed(() =>
accounts.value.find((account) => account.id === selectedAccountId.value) || null accounts.value.find((account) => account.id === selectedAccountId.value) || null
@@ -295,7 +304,10 @@ async function loadAccounts() {
loadingAccounts.value = true loadingAccounts.value = true
try { try {
accounts.value = await $api("/api/email/accounts") accounts.value = await $api("/api/email/accounts")
selectedAccountId.value = accounts.value[0]?.id || "" const requestedAccountId = String(route.query.account || "")
selectedAccountId.value = accounts.value.some((account) => account.id === requestedAccountId)
? requestedAccountId
: accounts.value[0]?.id || ""
if (selectedAccountId.value) { if (selectedAccountId.value) {
await loadMailboxes() await loadMailboxes()
} }
@@ -316,7 +328,9 @@ async function loadMailboxes() {
resetExpandedMailboxes() resetExpandedMailboxes()
const inbox = mailboxes.value.find((mailbox) => mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX") const inbox = mailboxes.value.find((mailbox) => mailbox.specialUse === "\\Inbox" || mailbox.path.toUpperCase() === "INBOX")
const previousMailbox = mailboxes.value.find((mailbox) => mailbox.path === previousMailboxPath) const previousMailbox = mailboxes.value.find((mailbox) => mailbox.path === previousMailboxPath)
selectedMailboxPath.value = previousMailbox?.path || inbox?.path || mailboxes.value[0]?.path || "INBOX" const requestedMailboxPath = String(route.query.mailbox || "")
const requestedMailbox = mailboxes.value.find((mailbox) => mailbox.path === requestedMailboxPath)
selectedMailboxPath.value = requestedMailbox?.path || previousMailbox?.path || inbox?.path || mailboxes.value[0]?.path || "INBOX"
expandMailboxAncestors(selectedMailboxPath.value) expandMailboxAncestors(selectedMailboxPath.value)
await loadMessages() await loadMessages()
} finally { } finally {
@@ -340,7 +354,10 @@ async function loadMessages(options: { syncIfEmpty?: boolean } = {}) {
} }
if (messages.value.length) { if (messages.value.length) {
await selectMessage(messages.value[0]) const requestedMessageId = deepLinkApplied ? "" : String(route.query.message || "")
const requestedMessage = messages.value.find((message) => message.id === requestedMessageId)
await selectMessage(requestedMessage || messages.value[0])
deepLinkApplied = true
} }
} finally { } finally {
loadingMessages.value = false loadingMessages.value = false
@@ -375,6 +392,22 @@ async function selectMessage(message: EmailMessage) {
} }
} }
function updateSelectedMessageEntityLinks(entityLinks: NonNullable<EmailMessage["entityLinks"]>) {
if (!selectedMessage.value) return
selectedMessage.value = { ...selectedMessage.value, entityLinks }
}
function openComposer(mode: "reply" | "replyAll" | "forward") {
if (!selectedMessage.value) return
navigateTo({
path: "/email/new",
query: {
mode,
source: selectedMessage.value.id,
},
})
}
function removeMessageFromCurrentList(messageId: string) { function removeMessageFromCurrentList(messageId: string) {
const currentIndex = messages.value.findIndex((message) => message.id === messageId) const currentIndex = messages.value.findIndex((message) => message.id === messageId)
messages.value = messages.value.filter((message) => message.id !== messageId) messages.value = messages.value.filter((message) => message.id !== messageId)
@@ -800,16 +833,26 @@ onMounted(loadAccounts)
color="neutral" color="neutral"
variant="soft" variant="soft"
size="sm" size="sm"
@click="navigateTo(`/email/new?to=${encodeURIComponent(formatAddressList(selectedMessage.from))}&subject=${encodeURIComponent(`Re: ${selectedMessage.subject || ''}`)}`)" @click="openComposer('reply')"
> >
Antworten Antworten
</UButton> </UButton>
<UButton
v-if="selectedMessage.cc?.length || (selectedMessage.to?.length || 0) > 1"
icon="i-heroicons-users"
color="neutral"
variant="ghost"
size="sm"
@click="openComposer('replyAll')"
>
Allen antworten
</UButton>
<UButton <UButton
icon="i-heroicons-arrow-uturn-right" icon="i-heroicons-arrow-uturn-right"
color="neutral" color="neutral"
variant="ghost" variant="ghost"
size="sm" size="sm"
@click="navigateTo(`/email/new?subject=${encodeURIComponent(`Fw: ${selectedMessage.subject || ''}`)}`)" @click="openComposer('forward')"
> >
Weiterleiten Weiterleiten
</UButton> </UButton>
@@ -831,6 +874,12 @@ onMounted(loadAccounts)
</p> </p>
</div> </div>
<EmailEntityLinks
:message-id="selectedMessage.id"
:entity-links="selectedMessage.entityLinks"
@updated="updateSelectedMessageEntityLinks"
/>
<div v-if="selectedMessage.attachments?.length" class="mt-4 flex flex-wrap gap-2"> <div v-if="selectedMessage.attachments?.length" class="mt-4 flex flex-wrap gap-2">
<button <button
v-for="attachment in selectedMessage.attachments" v-for="attachment in selectedMessage.attachments"

View File

@@ -21,6 +21,55 @@ const preloadedContent = ref("")
const loadedDocuments = ref([]) const loadedDocuments = ref([])
const loaded = ref(false) const loaded = ref(false)
const noAccountsPresent = ref(false) const noAccountsPresent = ref(false)
const sourceMessage = ref(null)
const composeMode = computed(() => {
const mode = String(route.query.mode || "")
return ["reply", "replyAll", "forward"].includes(mode) ? mode : "new"
})
const pageTitle = computed(() => ({
reply: "Antworten",
replyAll: "Allen antworten",
forward: "E-Mail weiterleiten",
}[composeMode.value] || "Neue E-Mail"))
const escapeHtml = (value = "") => String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
const formatAddress = (address) => {
if (!address?.address) return ""
return address.name ? `"${String(address.name).replace(/"/g, "\\\"")}" <${address.address}>` : address.address
}
const formatAddressList = (addresses = []) => addresses.map(formatAddress).filter(Boolean).join(", ")
const prefixedSubject = (prefix, subject = "") => {
const cleanSubject = String(subject || "")
return cleanSubject.toLowerCase().startsWith(prefix.toLowerCase()) ? cleanSubject : `${prefix} ${cleanSubject}`.trim()
}
const originalMessageBlock = (message, mode) => {
const sentAt = message.sentAt || message.receivedAt
const date = sentAt ? new Intl.DateTimeFormat("de-DE", {
dateStyle: "full",
timeStyle: "short",
}).format(new Date(sentAt)) : ""
const originalText = escapeHtml(message.body?.text || message.preview || "").replace(/\n/g, "<br>")
if (mode === "forward") {
return `<hr><p><strong>Weitergeleitete Nachricht</strong><br>`
+ `Von: ${escapeHtml(formatAddressList(message.from))}<br>`
+ `Datum: ${escapeHtml(date)}<br>`
+ `Betreff: ${escapeHtml(message.subject || "(kein Betreff)")}<br>`
+ `An: ${escapeHtml(formatAddressList(message.to))}</p><blockquote>${originalText}</blockquote>`
}
return `<hr><blockquote><p>Am ${escapeHtml(date)} schrieb ${escapeHtml(formatAddressList(message.from))}:</p>${originalText}</blockquote>`
}
const setupPage = async () => { const setupPage = async () => {
//emailAccounts.value = await useEntities("emailAccounts").select() //emailAccounts.value = await useEntities("emailAccounts").select()
@@ -31,7 +80,41 @@ const setupPage = async () => {
} else { } else {
emailData.value.account = emailAccounts.value[0].id emailData.value.account = emailAccounts.value[0].id
preloadedContent.value = `<p></p><p></p><p></p>${auth.profile.email_signature || ""}` let initialContent = `<p></p><p></p><p></p>${auth.profile.email_signature || ""}`
if (route.query.source && composeMode.value !== "new") {
sourceMessage.value = await useNuxtApp().$api(`/api/email/messages/${route.query.source}`)
const sourceAccount = emailAccounts.value.find(account => account.id === sourceMessage.value.accountId)
if (sourceAccount) emailData.value.account = sourceAccount.id
if (composeMode.value === "forward") {
emailData.value.subject = prefixedSubject("Fw:", sourceMessage.value.subject)
} else {
const ownAddress = String(sourceAccount?.email || "").toLowerCase()
const replyRecipients = sourceMessage.value.replyTo?.length
? sourceMessage.value.replyTo
: sourceMessage.value.from || []
if (composeMode.value === "replyAll") {
const allRecipients = [
...replyRecipients,
...(sourceMessage.value.to || []),
...(sourceMessage.value.cc || []),
].filter(address => address?.address && address.address.toLowerCase() !== ownAddress)
const uniqueRecipients = Array.from(new Map(
allRecipients.map(address => [address.address.toLowerCase(), address])
).values())
emailData.value.to = formatAddressList(uniqueRecipients)
} else {
emailData.value.to = formatAddressList(replyRecipients)
}
emailData.value.subject = prefixedSubject("Re:", sourceMessage.value.subject)
}
initialContent += originalMessageBlock(sourceMessage.value, composeMode.value)
}
preloadedContent.value = initialContent
//Check Query //Check Query
if(route.query.to) emailData.value.to = route.query.to if(route.query.to) emailData.value.to = route.query.to
@@ -120,7 +203,9 @@ const sendEmail = async () => {
let body = { let body = {
...emailData.value, ...emailData.value,
attachments: [] attachments: [],
sourceMessageId: sourceMessage.value?.id,
composeMode: composeMode.value === "new" ? undefined : composeMode.value,
} }
@@ -151,26 +236,28 @@ const sendEmail = async () => {
}) })
} }
console.log(body) try {
const res = await useNuxtApp().$api("/api/email/send",{
method: "POST",
body: body,
})
const res = await useNuxtApp().$api("/api/email/send",{ if(!res.success) {
method: "POST", toast.add({title: "Fehler beim Absenden der E-Mail", color: "error"})
body: body, } else {
}) await navigateTo("/email")
toast.add({title: "E-Mail gesendet", color: "success"})
console.log(res) }
} catch (err) {
toast.add({
if(!res.success) { title: "Fehler beim Absenden der E-Mail",
toast.add({title: "Fehler beim Absenden der E-Mail", color: "error"}) description: err?.data?.error || err?.message,
color: "error",
} else { })
navigateTo("/") } finally {
toast.add({title: "E-Mail zum Senden eingereiht"}) loaded.value = true
} }
loaded.value = true
} }
@@ -192,7 +279,7 @@ const sendEmail = async () => {
<div v-else> <div v-else>
<UDashboardNavbar <UDashboardNavbar
title="Neue E-Mail" :title="pageTitle"
> >
<template #right> <template #right>
<UButton <UButton
@@ -280,6 +367,12 @@ const sendEmail = async () => {
> >
<span v-if="doc.createddocument">Dokument - {{doc.createddocument.documentNumber}}</span> <span v-if="doc.createddocument">Dokument - {{doc.createddocument.documentNumber}}</span>
</li> </li>
<li
v-if="composeMode === 'forward' && sourceMessage?.attachments?.length"
class="list-disc"
>
{{ sourceMessage.attachments.length }} Originalanhang/Originalanhänge werden übernommen
</li>
</ul> </ul>
</div> </div>

View File

@@ -494,7 +494,7 @@ export const useDataStore = defineStore('data', () => {
inputColumn: "Allgemeines" inputColumn: "Allgemeines"
},*/ },*/
], ],
showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'},{label: 'Ausgehende SEPA-Mandate', key: 'outgoingsepamandates', type: 'outgoingsepamandates'},{label: 'Kundeninventar', key: 'customerinventoryitems'},{label: 'Kundenlagerplätze', key: 'customerspaces'},{label: 'Wiki'}] showTabs: [{label: 'Informationen'},{label: 'Ansprechpartner'},{label: 'E-Mails'},{label: 'Dateien'},{label: 'Ausgangsbelege'},{label: 'Projekte'},{label: 'Objekte'},{label: 'Termine'},{label: 'Verträge'},{label: 'Ausgehende SEPA-Mandate', key: 'outgoingsepamandates', type: 'outgoingsepamandates'},{label: 'Kundeninventar', key: 'customerinventoryitems'},{label: 'Kundenlagerplätze', key: 'customerspaces'},{label: 'Wiki'}]
}, },
members: { members: {
isArchivable: true, isArchivable: true,
@@ -1463,6 +1463,8 @@ export const useDataStore = defineStore('data', () => {
label: "Projekte" label: "Projekte"
},{ },{
label: "Aufgaben" label: "Aufgaben"
},{
label: "E-Mails"
},{ },{
label: "Dateien" label: "Dateien"
},{ },{
@@ -1741,6 +1743,8 @@ export const useDataStore = defineStore('data', () => {
},{ },{
key: "tasks", key: "tasks",
label: "Aufgaben" label: "Aufgaben"
},{
label: "E-Mails"
},{ },{
key: "files", key: "files",
label: "Dateien" label: "Dateien"
@@ -2024,6 +2028,8 @@ export const useDataStore = defineStore('data', () => {
label: 'Informationen', label: 'Informationen',
},{ },{
label: 'Ansprechpartner', label: 'Ansprechpartner',
}, {
label: 'E-Mails',
}, { }, {
label: 'Dateien', label: 'Dateien',
}, { }, {