Files
FEDEO/backend/src/routes/emailAsUser.ts
flfeders 1890d1d97d
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
feat(email): allow dismissing entity suggestions
2026-09-08 09:54:56 +02:00

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" })
}
})
}