feat(email): suggest entity links with AI when opening messages
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 48s
Build and Push Docker Images / build-frontend (push) Successful in 1m15s
Build and Push Docker Images / build-website (push) Successful in 23s
Build and Push Docker Images / build-central-services-api (push) Successful in 21s
Build and Push Docker Images / build-central-services-admin (push) Successful in 20s
Build and Push Docker Images / build-docs (push) Successful in 1m17s

This commit is contained in:
2026-09-08 09:42:17 +02:00
parent ed2885084e
commit a1e0ec6ac5
9 changed files with 294 additions and 5 deletions

View File

@@ -1,3 +1,4 @@
import { suggestEmailEntities, type EntityCandidate, type EntitySuggestion } from "../modules/email/email.entity-suggestions"
import nodemailer from "nodemailer"
import { FastifyInstance } from "fastify"
import { and, desc, eq, isNotNull } from "drizzle-orm"
@@ -21,6 +22,7 @@ import { ImapFlow } from "imapflow"
export default async function emailAsUserRoutes(server: FastifyInstance) {
const emailSync = emailSyncService(server)
const pendingSuggestions = new Map<string, Promise<EntitySuggestion[]>>()
const encryptedValue = (value: unknown) => value ? decrypt(value as any) : null
@@ -555,6 +557,66 @@ export default async function emailAsUserRoutes(server: FastifyInstance) {
}
})
server.post("/email/messages/:id/entity-suggestions", async (req, reply) => {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" })
try {
const { id } = req.params as { id: string }
const tenantId = req.user.tenant_id
const message = await emailSync.getMessage(tenantId, req.user.user_id, id)
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
const refresh = (req.body as { refresh?: boolean } | null)?.refresh === true
const key = `${tenantId}:${req.user.user_id}:${id}`
let suggestions = message.entitySuggestions
if (!suggestions || refresh) {
let pending = pendingSuggestions.get(key)
if (!pending) {
pending = (async () => {
const candidates: EntityCandidate[] = []
for (const [entityType, definition] of Object.entries(entityDefinitions)) {
const table = definition.table
const rows = await server.db.select({
id: table.id, name: table.name,
...("infoData" in table ? { infoData: table.infoData } : {}),
...(entityType === "customers" ? { number: customers.customerNumber }
: entityType === "vendors" ? { number: vendors.vendorNumber }
: entityType === "projects" ? { number: projects.projectNumber } : {}),
}).from(table).where(and(eq(definition.tenantColumn, tenantId), eq(table.archived, false)))
for (const row of rows as any[]) {
candidates.push({
entityType: entityType as EmailEntityType, entityId: row.id,
entityName: row.name, entityTypeLabel: definition.label, number: row.number,
email: typeof row.infoData?.email === "string" ? row.infoData.email : null,
invoiceEmail: typeof row.infoData?.invoiceEmail === "string" ? row.infoData.invoiceEmail : null,
})
}
}
const result = await suggestEmailEntities(message, candidates)
await server.db.update(emailMessages).set({ entitySuggestions: result }).where(and(
eq(emailMessages.id, id), eq(emailMessages.tenantId, tenantId),
eq(emailMessages.userId, req.user.user_id),
))
return result
})()
pendingSuggestions.set(key, pending)
void pending.finally(() => pendingSuggestions.delete(key)).catch(() => {})
}
suggestions = await pending
}
const links = await listMessageEntityLinks(tenantId, id)
const resolved = await Promise.all(suggestions.map(async suggestion => {
if (links.some(link => link.entityType === suggestion.entityType && link.entityId === suggestion.entityId)) return null
const entity = await loadEntity(tenantId, suggestion.entityType, suggestion.entityId)
return entity ? { ...suggestion, entityName: entity.name, entityTypeLabel: entity.typeLabel } : null
}))
return reply.send({ suggestions: resolved.filter(Boolean) })
} catch (err: any) {
req.log.error(err)
return reply.code(err.statusCode === 503 ? 503 : 500).send({
error: err.statusCode === 503 ? err.message : "KI-Vorschläge konnten nicht geladen werden. Bitte erneut versuchen.",
})
}
})
server.post("/email/messages/:id/entity-links", async (req, reply) => {
try {
if (!req.user?.tenant_id) {