All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 45s
Build and Push Docker Images / build-frontend (push) Successful in 1m16s
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 21s
Build and Push Docker Images / build-docs (push) Successful in 1m18s
901 lines
36 KiB
TypeScript
901 lines
36 KiB
TypeScript
import {
|
|
dismissEntitySuggestion,
|
|
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"
|
|
|
|
import { encrypt, decrypt } from "../utils/crypt"
|
|
import {
|
|
customers,
|
|
emailEntityLinks,
|
|
emailMessages,
|
|
emailSyncState,
|
|
plants,
|
|
projects,
|
|
userCredentials,
|
|
vendors,
|
|
} from "../../db/schema"
|
|
import { emailSyncService } from "../modules/email/email.sync.service"
|
|
|
|
// @ts-ignore
|
|
import MailComposer from "nodemailer/lib/mail-composer/index.js"
|
|
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
|
|
|
|
const accountResponse = (row: any) => {
|
|
const invalidEncryptedFields: string[] = []
|
|
const safeEncryptedValue = (value: unknown, field: string) => {
|
|
if (!value) return null
|
|
try {
|
|
return encryptedValue(value)
|
|
} catch {
|
|
invalidEncryptedFields.push(field)
|
|
return null
|
|
}
|
|
}
|
|
|
|
const email = safeEncryptedValue(row.emailEncrypted, "email")
|
|
const smtpHost = safeEncryptedValue(row.smtpHostEncrypted, "smtpHost")
|
|
const imapHost = safeEncryptedValue(row.imapHostEncrypted, "imapHost")
|
|
safeEncryptedValue(row.passwordEncrypted, "password")
|
|
|
|
if (invalidEncryptedFields.length) {
|
|
server.log.warn({
|
|
accountId: row.id,
|
|
invalidEncryptedFields,
|
|
}, "E-Mail-Kontodaten können mit dem aktuellen ENCRYPTION_KEY nicht entschlüsselt werden")
|
|
}
|
|
|
|
return {
|
|
id: row.id,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
userId: row.userId,
|
|
tenantId: row.tenantId,
|
|
type: row.type,
|
|
emailEnabled: row.emailEnabled !== false,
|
|
email,
|
|
displayName: email || `Mailkonto ${String(row.id).slice(0, 8)} muss repariert werden`,
|
|
smtpHost,
|
|
smtpPort: row.smtpPort ? Number(row.smtpPort) : null,
|
|
smtpSsl: row.smtpSsl,
|
|
imapHost,
|
|
imapPort: row.imapPort ? Number(row.imapPort) : null,
|
|
imapSsl: row.imapSsl,
|
|
hasPassword: Boolean(row.passwordEncrypted),
|
|
credentialsReadable: invalidEncryptedFields.length === 0,
|
|
invalidEncryptedFields,
|
|
}
|
|
}
|
|
|
|
const accountResponseWithStatus = async (row: any) => {
|
|
const syncRows = await server.db
|
|
.select({ lastSyncedAt: emailSyncState.lastSyncedAt })
|
|
.from(emailSyncState)
|
|
.where(and(
|
|
eq(emailSyncState.accountId, row.id),
|
|
eq(emailSyncState.tenantId, row.tenantId),
|
|
isNotNull(emailSyncState.lastSyncedAt),
|
|
))
|
|
.orderBy(desc(emailSyncState.lastSyncedAt))
|
|
.limit(1)
|
|
|
|
return {
|
|
...accountResponse(row),
|
|
lastSyncedAt: syncRows[0]?.lastSyncedAt || null,
|
|
}
|
|
}
|
|
|
|
const accountCredentials = (row: any) => {
|
|
const account = accountResponse(row)
|
|
if (!account.credentialsReadable) {
|
|
throw new Error("Die verschlüsselten Kontodaten sind nicht lesbar. Bitte das E-Mail-Konto in den Einstellungen vollständig neu speichern.")
|
|
}
|
|
|
|
return {
|
|
...account,
|
|
password: encryptedValue(row.passwordEncrypted),
|
|
}
|
|
}
|
|
|
|
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 (
|
|
origin
|
|
&& (
|
|
/^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin)
|
|
|| origin === "https://beta.fedeo.de"
|
|
|| origin === "https://app.fedeo.de"
|
|
|| origin === "capacitor://localhost"
|
|
)
|
|
) {
|
|
reply.header("Access-Control-Allow-Origin", origin)
|
|
reply.header("Access-Control-Allow-Credentials", "true")
|
|
reply.header("Vary", "Origin")
|
|
}
|
|
|
|
reply.header("Access-Control-Expose-Headers", "Authorization, Content-Disposition, Content-Type, Content-Length")
|
|
}
|
|
|
|
const accountWhere = (tenantId: number, userId: string, id?: string, enabledOnly = false) => {
|
|
const conditions = [
|
|
eq(userCredentials.tenantId, tenantId),
|
|
eq(userCredentials.userId, userId),
|
|
eq(userCredentials.type, "mail"),
|
|
]
|
|
if (id) conditions.push(eq(userCredentials.id, id))
|
|
if (enabledOnly) conditions.push(eq(userCredentials.emailEnabled, true))
|
|
return and(...conditions)
|
|
}
|
|
|
|
|
|
// ======================================================================
|
|
// CREATE OR UPDATE EMAIL ACCOUNT
|
|
// ======================================================================
|
|
server.post("/email/accounts/:id?", 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 {
|
|
email: string
|
|
password: string
|
|
smtpHost?: string
|
|
smtpPort?: number
|
|
smtpSsl?: boolean
|
|
imapHost?: string
|
|
imapPort?: number
|
|
imapSsl?: boolean
|
|
smtp_host?: string
|
|
smtp_port?: number
|
|
smtp_ssl?: boolean
|
|
imap_host?: string
|
|
imap_port?: number
|
|
imap_ssl?: boolean
|
|
emailEnabled?: boolean
|
|
}
|
|
|
|
// -----------------------------
|
|
// UPDATE EXISTING
|
|
// -----------------------------
|
|
if (id) {
|
|
const rows = await server.db
|
|
.select({ id: userCredentials.id })
|
|
.from(userCredentials)
|
|
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
|
|
.limit(1)
|
|
|
|
if (!rows[0]) return reply.code(404).send({ error: "Account not found" })
|
|
|
|
const saveData = {
|
|
emailEncrypted: body.email ? encrypt(body.email) : undefined,
|
|
passwordEncrypted: body.password ? encrypt(body.password) : undefined,
|
|
smtpHostEncrypted: bodyValue(body, "smtpHost", "smtp_host") ? encrypt(bodyValue(body, "smtpHost", "smtp_host")) : undefined,
|
|
smtpPort: bodyValue(body, "smtpPort", "smtp_port"),
|
|
smtpSsl: bodyValue(body, "smtpSsl", "smtp_ssl"),
|
|
imapHostEncrypted: bodyValue(body, "imapHost", "imap_host") ? encrypt(bodyValue(body, "imapHost", "imap_host")) : undefined,
|
|
imapPort: bodyValue(body, "imapPort", "imap_port"),
|
|
imapSsl: bodyValue(body, "imapSsl", "imap_ssl"),
|
|
emailEnabled: typeof body.emailEnabled === "boolean" ? body.emailEnabled : undefined,
|
|
updatedAt: new Date(),
|
|
}
|
|
|
|
await server.db
|
|
.update(userCredentials)
|
|
//@ts-ignore
|
|
.set(saveData)
|
|
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
|
|
|
|
return reply.send({ success: true })
|
|
}
|
|
|
|
// -----------------------------
|
|
// CREATE NEW
|
|
// -----------------------------
|
|
const insertData = {
|
|
userId: req.user.user_id,
|
|
tenantId: req.user.tenant_id,
|
|
type: "mail",
|
|
emailEnabled: body.emailEnabled !== false,
|
|
|
|
emailEncrypted: encrypt(body.email),
|
|
passwordEncrypted: encrypt(body.password),
|
|
|
|
smtpHostEncrypted: encrypt(bodyValue(body, "smtpHost", "smtp_host")),
|
|
smtpPort: bodyValue(body, "smtpPort", "smtp_port"),
|
|
smtpSsl: bodyValue(body, "smtpSsl", "smtp_ssl"),
|
|
|
|
imapHostEncrypted: encrypt(bodyValue(body, "imapHost", "imap_host")),
|
|
imapPort: bodyValue(body, "imapPort", "imap_port"),
|
|
imapSsl: bodyValue(body, "imapSsl", "imap_ssl"),
|
|
}
|
|
|
|
//@ts-ignore
|
|
await server.db.insert(userCredentials).values(insertData)
|
|
|
|
return reply.send({ success: true })
|
|
} catch (err) {
|
|
console.error("POST /email/accounts error:", err)
|
|
return reply.code(500).send({ error: "Internal Server Error" })
|
|
}
|
|
})
|
|
|
|
|
|
|
|
// ======================================================================
|
|
// GET SINGLE OR ALL ACCOUNTS
|
|
// ======================================================================
|
|
server.get("/email/accounts/:id?", 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 query = (req.query || {}) as { includeDisabled?: string | boolean }
|
|
|
|
// ============================================================
|
|
// LOAD SINGLE ACCOUNT
|
|
// ============================================================
|
|
if (id) {
|
|
const rows = await server.db
|
|
.select()
|
|
.from(userCredentials)
|
|
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
|
|
.limit(1)
|
|
|
|
const row = rows[0]
|
|
if (!row) return reply.code(404).send({ error: "Not found" })
|
|
|
|
return reply.send(await accountResponseWithStatus(row))
|
|
}
|
|
|
|
// ============================================================
|
|
// LOAD ALL ACCOUNTS FOR TENANT
|
|
// ============================================================
|
|
const rows = await server.db
|
|
.select()
|
|
.from(userCredentials)
|
|
.where(accountWhere(
|
|
req.user.tenant_id,
|
|
req.user.user_id,
|
|
undefined,
|
|
query.includeDisabled !== "true" && query.includeDisabled !== true,
|
|
))
|
|
|
|
return reply.send(await Promise.all(rows.map(accountResponseWithStatus)))
|
|
|
|
} catch (err) {
|
|
console.error("GET /email/accounts error:", err)
|
|
return reply.code(500).send({ error: "Internal Server Error" })
|
|
}
|
|
})
|
|
|
|
|
|
|
|
// ======================================================================
|
|
// SEND EMAIL + SAVE IN IMAP SENT FOLDER
|
|
// ======================================================================
|
|
server.post("/email/send", async (req, reply) => {
|
|
try {
|
|
const body = req.body as {
|
|
to: string
|
|
cc?: string
|
|
bcc?: string
|
|
subject?: string
|
|
text?: string
|
|
html?: string
|
|
attachments?: any
|
|
account: string
|
|
sourceMessageId?: string
|
|
composeMode?: "reply" | "replyAll" | "forward"
|
|
}
|
|
|
|
// Fetch email credentials
|
|
const rows = await server.db
|
|
.select()
|
|
.from(userCredentials)
|
|
.where(accountWhere(req.user.tenant_id, req.user.user_id, body.account, true))
|
|
.limit(1)
|
|
|
|
const row = rows[0]
|
|
if (!row) return reply.code(404).send({ error: "Account not found" })
|
|
|
|
const accountData = accountCredentials(row)
|
|
|
|
// -------------------------
|
|
// SEND EMAIL VIA SMTP
|
|
// -------------------------
|
|
const transporter = nodemailer.createTransport({
|
|
host: accountData.smtpHost,
|
|
port: accountData.smtpPort,
|
|
secure: accountData.smtpSsl,
|
|
auth: {
|
|
user: accountData.email,
|
|
pass: accountData.password,
|
|
},
|
|
})
|
|
|
|
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,
|
|
cc: body.cc,
|
|
bcc: body.bcc,
|
|
subject: body.subject,
|
|
html: body.html,
|
|
text: body.text,
|
|
attachments,
|
|
inReplyTo: isReply ? sourceMessage?.messageId || undefined : undefined,
|
|
references: isReply && sourceMessage?.messageId ? [sourceMessage.messageId] : undefined,
|
|
}
|
|
|
|
const info = await transporter.sendMail(message)
|
|
|
|
// -------------------------
|
|
// SAVE TO IMAP SENT FOLDER
|
|
// -------------------------
|
|
const imap = new ImapFlow({
|
|
host: accountData.imapHost,
|
|
port: accountData.imapPort,
|
|
secure: accountData.imapSsl,
|
|
auth: {
|
|
user: accountData.email,
|
|
pass: accountData.password,
|
|
},
|
|
})
|
|
|
|
await imap.connect()
|
|
|
|
const mail = new MailComposer(message)
|
|
const raw = await mail.compile().build()
|
|
|
|
let savedToSent = false
|
|
for await (const mailbox of await imap.list()) {
|
|
if (mailbox.specialUse === "\\Sent") {
|
|
await imap.mailboxOpen(mailbox.path)
|
|
await imap.append(mailbox.path, raw, ["\\Seen"])
|
|
savedToSent = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!savedToSent) {
|
|
const sentFallbacks = ["Sent", "Gesendet", "INBOX.Sent"]
|
|
for (const path of sentFallbacks) {
|
|
try {
|
|
await imap.append(path, raw, ["\\Seen"])
|
|
savedToSent = true
|
|
break
|
|
} catch (err) {
|
|
// Fallback wird nur genutzt, wenn der Ordner existiert.
|
|
}
|
|
}
|
|
}
|
|
|
|
await imap.logout()
|
|
|
|
return reply.send({ success: true })
|
|
|
|
} catch (err) {
|
|
console.error("POST /email/send error:", err)
|
|
return reply.code(500).send({ error: "Failed to send email" })
|
|
}
|
|
})
|
|
|
|
server.post("/email/accounts/:id/sync", 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 { mailbox?: string; limit?: number }
|
|
|
|
const accountRows = await server.db
|
|
.select({ emailEnabled: userCredentials.emailEnabled })
|
|
.from(userCredentials)
|
|
.where(accountWhere(req.user.tenant_id, req.user.user_id, id))
|
|
.limit(1)
|
|
|
|
if (!accountRows[0]) return reply.code(404).send({ error: "E-Mail-Konto nicht gefunden" })
|
|
if (!accountRows[0].emailEnabled) {
|
|
return reply.code(409).send({ error: "Das E-Mail-Konto ist deaktiviert" })
|
|
}
|
|
|
|
const result = await emailSync.syncAccount(
|
|
req.user.tenant_id,
|
|
req.user.user_id,
|
|
id,
|
|
body,
|
|
)
|
|
|
|
return reply.send({ success: true, ...result })
|
|
} catch (err: any) {
|
|
req.log.error(err)
|
|
return reply.code(500).send({ error: err.message || "E-Mail Sync fehlgeschlagen" })
|
|
}
|
|
})
|
|
|
|
server.get("/email/accounts/:id/mailboxes", 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 }
|
|
return reply.send(await emailSync.listMailboxes(req.user.tenant_id, req.user.user_id, id))
|
|
} catch (err: any) {
|
|
req.log.error(err)
|
|
return reply.code(500).send({ error: err.message || "Postfächer konnten nicht geladen werden" })
|
|
}
|
|
})
|
|
|
|
server.get("/email/accounts/:id/messages", 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 query = req.query as { mailbox?: string; limit?: string }
|
|
|
|
return reply.send(await emailSync.listMessages(
|
|
req.user.tenant_id,
|
|
req.user.user_id,
|
|
id,
|
|
query.mailbox || "INBOX",
|
|
Number(query.limit || 50),
|
|
))
|
|
} catch (err: any) {
|
|
req.log.error(err)
|
|
return reply.code(500).send({ error: err.message || "E-Mails konnten nicht geladen werden" })
|
|
}
|
|
})
|
|
|
|
server.get("/email/messages/:id", 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 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,
|
|
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-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.delete("/email/messages/:id/entity-suggestions/:entityType/:entityId", async (req, reply) => {
|
|
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" })
|
|
try {
|
|
const { id, entityType, entityId: rawEntityId } = req.params as {
|
|
id: string
|
|
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" })
|
|
}
|
|
|
|
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 suggestions = dismissEntitySuggestion(message.entitySuggestions, entityType, entityId)
|
|
await server.db.update(emailMessages).set({ entitySuggestions: suggestions }).where(and(
|
|
eq(emailMessages.id, id),
|
|
eq(emailMessages.tenantId, req.user.tenant_id),
|
|
eq(emailMessages.userId, req.user.user_id),
|
|
))
|
|
return reply.send({ success: true, suggestions })
|
|
} catch (err: any) {
|
|
req.log.error(err)
|
|
return reply.code(500).send({ error: "Vorschlag konnte nicht ausgeblendet 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) {
|
|
return reply.code(400).send({ error: "No tenant selected" })
|
|
}
|
|
|
|
const { id } = req.params as { id: string }
|
|
const body = (req.body || {}) as { seen?: boolean }
|
|
const message = await emailSync.setMessageSeen(
|
|
req.user.tenant_id,
|
|
req.user.user_id,
|
|
id,
|
|
body.seen !== false,
|
|
)
|
|
|
|
if (!message) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
|
|
|
return reply.send({ success: true, message })
|
|
} catch (err: any) {
|
|
req.log.error(err)
|
|
return reply.code(500).send({ error: err.message || "Lesestatus konnte nicht synchronisiert werden" })
|
|
}
|
|
})
|
|
|
|
server.post("/email/messages/:id/move", 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 { mailbox?: string }
|
|
|
|
if (!body.mailbox) {
|
|
return reply.code(400).send({ error: "Zielordner fehlt" })
|
|
}
|
|
|
|
const result = await emailSync.moveMessage(
|
|
req.user.tenant_id,
|
|
req.user.user_id,
|
|
id,
|
|
body.mailbox,
|
|
)
|
|
|
|
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
|
return reply.send({ success: true, ...result })
|
|
} catch (err: any) {
|
|
req.log.error(err)
|
|
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht verschoben werden" })
|
|
}
|
|
})
|
|
|
|
server.post("/email/messages/:id/archive", 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 result = await emailSync.archiveMessage(req.user.tenant_id, req.user.user_id, id)
|
|
|
|
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
|
return reply.send({ success: true, ...result })
|
|
} catch (err: any) {
|
|
req.log.error(err)
|
|
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht archiviert werden" })
|
|
}
|
|
})
|
|
|
|
server.delete("/email/messages/:id", 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 result = await emailSync.deleteMessage(req.user.tenant_id, req.user.user_id, id)
|
|
|
|
if (!result) return reply.code(404).send({ error: "E-Mail nicht gefunden" })
|
|
return reply.send({ success: true })
|
|
} catch (err: any) {
|
|
req.log.error(err)
|
|
return reply.code(500).send({ error: err.message || "E-Mail konnte nicht gelöscht werden" })
|
|
}
|
|
})
|
|
|
|
server.get("/email/attachments/:id/download", async (req, reply) => {
|
|
applyDownloadCorsHeaders(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 attachment = await emailSync.getAttachmentContent(req.user.tenant_id, req.user.user_id, id)
|
|
|
|
if (!attachment) return reply.code(404).send({ error: "Anhang nicht gefunden" })
|
|
|
|
const buffer = Buffer.isBuffer(attachment.content)
|
|
? attachment.content
|
|
: Buffer.from(attachment.content)
|
|
const filename = attachment.filename.replace(/["\r\n]/g, "")
|
|
|
|
reply.header("Content-Type", attachment.contentType || "application/octet-stream")
|
|
reply.header("Content-Length", buffer.length)
|
|
reply.header("Cache-Control", "no-store")
|
|
reply.header("Content-Disposition", `attachment; filename="${filename}"`)
|
|
return reply.send(buffer)
|
|
} catch (err: any) {
|
|
req.log.error(err)
|
|
return reply.code(500).send({ error: err.message || "Anhang konnte nicht geladen werden" })
|
|
}
|
|
})
|
|
|
|
}
|