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"
/>
+