From f3384cfc741669e92824d96dcbdd68828d0717c9 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 7 Sep 2026 19:00:08 +0200 Subject: [PATCH] =?UTF-8?q?KI-AGENT:=20Mailantworten=20und=20Entit=C3=A4ts?= =?UTF-8?q?verkn=C3=BCpfungen=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../db/migrations/0066_email_entity_links.sql | 27 +++ backend/db/migrations/meta/_journal.json | 7 + backend/db/schema/emails.ts | 35 +++ backend/src/routes/emailAsUser.ts | 227 +++++++++++++++++- frontend/components/EntityShow.vue | 10 + .../components/email/EmailEntityLinks.vue | 169 +++++++++++++ .../components/email/EmailEntityMessages.vue | 129 ++++++++++ frontend/pages/email/index.vue | 59 ++++- frontend/pages/email/new.vue | 133 ++++++++-- frontend/stores/data.js | 8 +- 10 files changed, 774 insertions(+), 30 deletions(-) create mode 100644 backend/db/migrations/0066_email_entity_links.sql create mode 100644 frontend/components/email/EmailEntityLinks.vue create mode 100644 frontend/components/email/EmailEntityMessages.vue diff --git a/backend/db/migrations/0066_email_entity_links.sql b/backend/db/migrations/0066_email_entity_links.sql new file mode 100644 index 0000000..8fd43d7 --- /dev/null +++ b/backend/db/migrations/0066_email_entity_links.sql @@ -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"); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 032bf0c..c8904de 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -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 } ] } diff --git a/backend/db/schema/emails.ts b/backend/db/schema/emails.ts index 1a600eb..035c1cc 100644 --- a/backend/db/schema/emails.ts +++ b/backend/db/schema/emails.ts @@ -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 diff --git a/backend/src/routes/emailAsUser.ts b/backend/src/routes/emailAsUser.ts index 37e42e3..ff68692 100644 --- a/backend/src/routes/emailAsUser.ts +++ b/backend/src/routes/emailAsUser.ts @@ -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) { diff --git a/frontend/components/EntityShow.vue b/frontend/components/EntityShow.vue index 9cca494..b8aa1d3 100644 --- a/frontend/components/EntityShow.vue +++ b/frontend/components/EntityShow.vue @@ -366,6 +366,11 @@ const invitePortalUser = async () => { v-else-if="tab.label === 'Zeiten'" :platform="platform" /> +
{ @updateNeeded="emit('updateNeeded')" :platform="platform" /> +