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