KI-AGENT: Mailantworten und Entitätsverknüpfungen ergänzen
This commit is contained in:
27
backend/db/migrations/0066_email_entity_links.sql
Normal file
27
backend/db/migrations/0066_email_entity_links.sql
Normal 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");
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
169
frontend/components/email/EmailEntityLinks.vue
Normal file
169
frontend/components/email/EmailEntityLinks.vue
Normal 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>
|
||||||
129
frontend/components/email/EmailEntityMessages.vue
Normal file
129
frontend/components/email/EmailEntityMessages.vue
Normal 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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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',
|
||||||
}, {
|
}, {
|
||||||
|
|||||||
Reference in New Issue
Block a user