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,
|
||||
"tag": "0065_document_templates",
|
||||
"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(
|
||||
"email_sync_state",
|
||||
{
|
||||
@@ -204,5 +237,7 @@ export type EmailMessageBody = typeof emailMessageBodies.$inferSelect
|
||||
export type NewEmailMessageBody = typeof emailMessageBodies.$inferInsert
|
||||
export type EmailAttachment = typeof emailAttachments.$inferSelect
|
||||
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 NewEmailSyncState = typeof emailSyncState.$inferInsert
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import nodemailer from "nodemailer"
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
|
||||
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"
|
||||
|
||||
// @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 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 origin = req.headers.origin
|
||||
if (
|
||||
@@ -221,6 +276,8 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
html?: string
|
||||
attachments?: any
|
||||
account: string
|
||||
sourceMessageId?: string
|
||||
composeMode?: "reply" | "replyAll" | "forward"
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
from: accountData.email,
|
||||
to: body.to,
|
||||
@@ -256,7 +342,9 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
|
||||
subject: body.subject,
|
||||
html: body.html,
|
||||
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)
|
||||
@@ -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)
|
||||
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) {
|
||||
req.log.error(err)
|
||||
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) => {
|
||||
try {
|
||||
if (!req.user?.tenant_id) {
|
||||
|
||||
Reference in New Issue
Block a user