From 90565d5137455537797f19cc65e311c28e8bfe96 Mon Sep 17 00:00:00 2001 From: dufyfduck Date: Tue, 3 Feb 2026 23:58:00 +0100 Subject: [PATCH] save attachment from email in customer data and - or contracts --- README.md | 37 ++ backend/src/config/documentTargets.config.ts | 141 ++++++ .../src/controllers/cachedEmail.controller.ts | 467 ++++++++++++++++++ backend/src/routes/cachedEmail.routes.ts | 18 + backend/src/services/imapService.ts | 20 +- .../1770159143387-113118573.pdf | Bin 0 -> 8669 bytes .../1770159060770-214871653.pdf | Bin 0 -> 8669 bytes frontend/src/components/email/EmailDetail.tsx | 24 +- .../components/email/SaveAttachmentModal.tsx | 295 +++++++++++ frontend/src/services/api.ts | 46 ++ 10 files changed, 1037 insertions(+), 11 deletions(-) create mode 100644 backend/src/config/documentTargets.config.ts create mode 100644 backend/uploads/business-registrations/1770159143387-113118573.pdf create mode 100644 backend/uploads/privacy-policies/1770159060770-214871653.pdf create mode 100644 frontend/src/components/email/SaveAttachmentModal.tsx diff --git a/README.md b/README.md index 37967c0a..a21aeee3 100644 --- a/README.md +++ b/README.md @@ -488,6 +488,7 @@ model CachedEmail { |--------|--------------| | E-Mails lesen | `customers:read` | | E-Mails senden, markieren | `customers:update` | +| Anhänge in Dokumente speichern | `customers:update` | | Vertrag zuordnen | `contracts:update` | | Löschen, Papierkorb | `emails:delete` | @@ -502,6 +503,42 @@ model CachedEmail { | `TrashEmailList.tsx` | Papierkorb-Verwaltung | | `AssignToContractModal.tsx` | Vertragszuordnung | | `ContractEmailsSection.tsx` | E-Mails in Vertragsansicht | +| `SaveAttachmentModal.tsx` | Anhänge in Dokumentfelder speichern | + +### Anhänge als Dokumente speichern + +E-Mail-Anhänge können direkt in Dokumentfelder des CRM gespeichert werden. Über den blauen Speichern-Button (💾) neben jedem Anhang öffnet sich ein Modal mit allen verfügbaren Zielen. + +#### Verfügbare Ziele + +| Kategorie | Dokumentfelder | +|-----------|----------------| +| **Kunde** | Datenschutzerklärung | +| **Kunde (Gewerbe)** | + Gewerbeanmeldung, Handelsregisterauszug | +| **Ausweisdokumente** | Dokumentscan (pro Ausweis) | +| **Bankkarten** | Kartenscan (pro Karte) | +| **Vertrag** | Kündigungsschreiben, Kündigungsbestätigung, Kündigungsschreiben (Optionen), Kündigungsbestätigung (Optionen) | + +> **Hinweis:** Vertragsdokumente sind nur verfügbar, wenn die E-Mail einem Vertrag zugeordnet ist. + +#### Dynamische Konfiguration + +Die Dokumentziele werden zentral in `backend/src/config/documentTargets.config.ts` konfiguriert. Neue Dokumentfelder werden automatisch im Modal angezeigt, ohne Frontend-Änderungen. + +```typescript +// Beispiel: Neues Feld hinzufügen +{ + key: 'newDocument', + label: 'Neues Dokument', + field: 'newDocumentPath', // Prisma-Feld + condition: null, // oder 'BUSINESS' für Geschäftskunden + directory: 'new-documents' // Upload-Verzeichnis +} +``` + +#### Warnung bei Überschreiben + +Wenn bereits ein Dokument im Zielfeld vorhanden ist, wird eine Warnung angezeigt. Das vorhandene Dokument wird beim Speichern automatisch ersetzt und die alte Datei gelöscht. ### Vertragszuordnung aufheben (X-Button) diff --git a/backend/src/config/documentTargets.config.ts b/backend/src/config/documentTargets.config.ts new file mode 100644 index 00000000..f0d50724 --- /dev/null +++ b/backend/src/config/documentTargets.config.ts @@ -0,0 +1,141 @@ +/** + * Zentrale Konfiguration aller Dokumenten-Ziele für E-Mail-Anhänge. + * + * Bei neuen Dokumentfeldern einfach hier hinzufügen: + * 1. Neues Feld in der entsprechenden Kategorie anlegen + * 2. Upload-Route in upload.routes.ts hinzufügen (falls noch nicht vorhanden) + * 3. Das Frontend zeigt das neue Feld automatisch an + */ + +export interface DocumentTarget { + /** Eindeutiger Schlüssel für das Feld */ + key: string; + /** Anzeigename im UI */ + label: string; + /** Datenbankfeld-Name (z.B. 'privacyPolicyPath') */ + field: string; + /** Bedingung für Anzeige: 'BUSINESS' = nur Geschäftskunden, null = immer */ + condition?: 'BUSINESS' | null; + /** Upload-Verzeichnis (relativ zu /uploads/) */ + directory: string; +} + +export interface DocumentTargetConfig { + customer: DocumentTarget[]; + contract: DocumentTarget[]; + identityDocument: DocumentTarget[]; + bankCard: DocumentTarget[]; +} + +/** + * Alle verfügbaren Dokumenten-Ziele gruppiert nach Entity-Typ. + * + * WICHTIG: Bei neuen Feldern hier hinzufügen! + */ +export const documentTargets: DocumentTargetConfig = { + // Dokumente direkt am Kunden + customer: [ + { + key: 'privacyPolicy', + label: 'Datenschutzerklärung', + field: 'privacyPolicyPath', + condition: null, // Für alle Kunden + directory: 'privacy-policies', + }, + { + key: 'businessRegistration', + label: 'Gewerbeanmeldung', + field: 'businessRegistrationPath', + condition: 'BUSINESS', // Nur Geschäftskunden + directory: 'business-registrations', + }, + { + key: 'commercialRegister', + label: 'Handelsregisterauszug', + field: 'commercialRegisterPath', + condition: 'BUSINESS', // Nur Geschäftskunden + directory: 'commercial-registers', + }, + ], + + // Dokumente am Vertrag + contract: [ + { + key: 'cancellationLetter', + label: 'Kündigungsschreiben', + field: 'cancellationLetterPath', + directory: 'cancellation-letters', + }, + { + key: 'cancellationConfirmation', + label: 'Kündigungsbestätigung', + field: 'cancellationConfirmationPath', + directory: 'cancellation-confirmations', + }, + { + key: 'cancellationLetterOptions', + label: 'Kündigungsschreiben (Optionen)', + field: 'cancellationLetterOptionsPath', + directory: 'cancellation-letters-options', + }, + { + key: 'cancellationConfirmationOptions', + label: 'Kündigungsbestätigung (Optionen)', + field: 'cancellationConfirmationOptionsPath', + directory: 'cancellation-confirmations-options', + }, + ], + + // Dokumente an Ausweisen (pro Ausweis-Eintrag) + identityDocument: [ + { + key: 'document', + label: 'Ausweis-Scan', + field: 'documentPath', + directory: 'documents', + }, + ], + + // Dokumente an Bankkarten (pro Bankkarte) + bankCard: [ + { + key: 'document', + label: 'Bankkarten-Dokument', + field: 'documentPath', + directory: 'bank-cards', + }, + ], +}; + +/** + * Hilfsfunktion: Gibt alle Kunden-Dokumentziele zurück, + * gefiltert nach Kundentyp (PRIVATE/BUSINESS) + */ +export function getCustomerTargets(customerType: 'PRIVATE' | 'BUSINESS'): DocumentTarget[] { + return documentTargets.customer.filter(target => { + if (target.condition === null) return true; + if (target.condition === 'BUSINESS' && customerType === 'BUSINESS') return true; + return false; + }); +} + +/** + * Hilfsfunktion: Gibt alle Vertrags-Dokumentziele zurück + */ +export function getContractTargets(): DocumentTarget[] { + return documentTargets.contract; +} + +/** + * Hilfsfunktion: Gibt das Ausweis-Dokumentziel zurück + */ +export function getIdentityDocumentTargets(): DocumentTarget[] { + return documentTargets.identityDocument; +} + +/** + * Hilfsfunktion: Gibt das Bankkarten-Dokumentziel zurück + */ +export function getBankCardTargets(): DocumentTarget[] { + return documentTargets.bankCard; +} diff --git a/backend/src/controllers/cachedEmail.controller.ts b/backend/src/controllers/cachedEmail.controller.ts index 52b9921f..f1819d35 100644 --- a/backend/src/controllers/cachedEmail.controller.ts +++ b/backend/src/controllers/cachedEmail.controller.ts @@ -8,6 +8,12 @@ import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imap import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js'; import { decrypt } from '../utils/encryption.js'; import { ApiResponse } from '../types/index.js'; +import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js'; +import { PrismaClient, DocumentType } from '@prisma/client'; +import path from 'path'; +import fs from 'fs'; + +const prisma = new PrismaClient(); // ==================== E-MAIL LIST ==================== @@ -803,3 +809,464 @@ export async function permanentDeleteEmail(req: Request, res: Response): Promise } as ApiResponse); } } + +// ==================== ATTACHMENT TARGETS ==================== + +// Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen +export async function getAttachmentTargets(req: Request, res: Response): Promise { + try { + const emailId = parseInt(req.params.id); + + // E-Mail mit StressfreiEmail laden + const email = await cachedEmailService.getCachedEmailById(emailId); + if (!email) { + res.status(404).json({ + success: false, + error: 'E-Mail nicht gefunden', + } as ApiResponse); + return; + } + + // StressfreiEmail laden um an den Kunden zu kommen + const stressfreiEmail = await prisma.stressfreiEmail.findUnique({ + where: { id: email.stressfreiEmailId }, + include: { + customer: { + include: { + identityDocuments: { + where: { isActive: true }, + select: { id: true, type: true, documentNumber: true, documentPath: true }, + }, + bankCards: { + where: { isActive: true }, + select: { id: true, iban: true, bankName: true, documentPath: true }, + }, + }, + }, + }, + }); + + if (!stressfreiEmail) { + res.status(404).json({ + success: false, + error: 'E-Mail-Konto nicht gefunden', + } as ApiResponse); + return; + } + + const customer = stressfreiEmail.customer; + const customerType = customer.type as 'PRIVATE' | 'BUSINESS'; + + // Ergebnis-Struktur + interface TargetSlot { + key: string; + label: string; + field: string; + hasDocument: boolean; + currentPath?: string; + } + + interface EntityWithSlots { + id: number; + label: string; + slots: TargetSlot[]; + } + + interface AttachmentTargetsResponse { + customer: { + id: number; + name: string; + type: string; + slots: TargetSlot[]; + }; + identityDocuments: EntityWithSlots[]; + bankCards: EntityWithSlots[]; + contract?: { + id: number; + contractNumber: string; + slots: TargetSlot[]; + }; + } + + // Kunden-Dokumentziele (gefiltert nach Kundentyp) + const customerTargets = getCustomerTargets(customerType); + const customerSlots: TargetSlot[] = customerTargets.map(target => ({ + key: target.key, + label: target.label, + field: target.field, + hasDocument: !!(customer as any)[target.field], + currentPath: (customer as any)[target.field] || undefined, + })); + + // Ausweis-Dokumentziele + const identityTargets = getIdentityDocumentTargets(); + const identityDocuments: EntityWithSlots[] = customer.identityDocuments.map((doc: { id: number; type: DocumentType; documentNumber: string; documentPath: string | null }) => { + const docTypeLabels: Record = { + ID_CARD: 'Personalausweis', + PASSPORT: 'Reisepass', + DRIVERS_LICENSE: 'Führerschein', + OTHER: 'Sonstiges', + }; + return { + id: doc.id, + label: `${docTypeLabels[doc.type] || doc.type}: ${doc.documentNumber}`, + slots: identityTargets.map(target => ({ + key: target.key, + label: target.label, + field: target.field, + hasDocument: !!(doc as Record)[target.field], + currentPath: ((doc as Record)[target.field] as string | undefined) || undefined, + })), + }; + }); + + // Bankkarten-Dokumentziele + const bankCardTargets = getBankCardTargets(); + const bankCards: EntityWithSlots[] = customer.bankCards.map((card: { id: number; iban: string; bankName: string | null; documentPath: string | null }) => ({ + id: card.id, + label: `${card.bankName || 'Bank'}: ${card.iban.slice(-4)}`, + slots: bankCardTargets.map(target => ({ + key: target.key, + label: target.label, + field: target.field, + hasDocument: !!(card as Record)[target.field], + currentPath: ((card as Record)[target.field] as string | undefined) || undefined, + })), + })); + + // Basis-Antwort + const response: AttachmentTargetsResponse = { + customer: { + id: customer.id, + name: customer.companyName || `${customer.firstName} ${customer.lastName}`, + type: customerType, + slots: customerSlots, + }, + identityDocuments, + bankCards, + }; + + // Vertrag hinzufügen, falls E-Mail einem Vertrag zugeordnet ist + if (email.contractId) { + const contract = await prisma.contract.findUnique({ + where: { id: email.contractId }, + select: { + id: true, + contractNumber: true, + cancellationLetterPath: true, + cancellationConfirmationPath: true, + cancellationLetterOptionsPath: true, + cancellationConfirmationOptionsPath: true, + }, + }); + + if (contract) { + const contractTargets = getContractTargets(); + response.contract = { + id: contract.id, + contractNumber: contract.contractNumber, + slots: contractTargets.map(target => ({ + key: target.key, + label: target.label, + field: target.field, + hasDocument: !!(contract as any)[target.field], + currentPath: (contract as any)[target.field] || undefined, + })), + }; + } + } + + res.json({ success: true, data: response } as ApiResponse); + } catch (error) { + console.error('getAttachmentTargets error:', error); + res.status(500).json({ + success: false, + error: 'Fehler beim Laden der Dokumenten-Ziele', + } as ApiResponse); + } +} + +// E-Mail-Anhang in ein Dokumentenfeld speichern +export async function saveAttachmentTo(req: Request, res: Response): Promise { + try { + const emailId = parseInt(req.params.id); + const filename = decodeURIComponent(req.params.filename); + const { entityType, entityId, targetKey } = req.body; + + console.log('[saveAttachmentTo] Request:', { emailId, filename, entityType, entityId, targetKey }); + + // Validierung + if (!entityType || !targetKey) { + res.status(400).json({ + success: false, + error: 'entityType und targetKey sind erforderlich', + } as ApiResponse); + return; + } + + // E-Mail aus Cache laden + const email = await cachedEmailService.getCachedEmailById(emailId); + if (!email) { + res.status(404).json({ + success: false, + error: 'E-Mail nicht gefunden', + } as ApiResponse); + return; + } + + // Für gesendete E-Mails: Prüfen ob UID vorhanden (im IMAP Sent gespeichert) + if (email.folder === 'SENT' && email.uid === 0) { + res.status(400).json({ + success: false, + error: 'Anhang nicht verfügbar - E-Mail wurde vor der IMAP-Speicherung gesendet', + } as ApiResponse); + return; + } + + // StressfreiEmail laden um Zugangsdaten zu bekommen + const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(email.stressfreiEmailId); + if (!stressfreiEmail || !stressfreiEmail.emailPasswordEncrypted) { + res.status(400).json({ + success: false, + error: 'Keine Mailbox-Zugangsdaten verfügbar', + } as ApiResponse); + return; + } + + // IMAP-Einstellungen laden + const settings = await getImapSmtpSettings(); + if (!settings) { + res.status(400).json({ + success: false, + error: 'Keine E-Mail-Provider-Einstellungen gefunden', + } as ApiResponse); + return; + } + + // Passwort entschlüsseln + const password = decrypt(stressfreiEmail.emailPasswordEncrypted); + + // IMAP-Credentials + const credentials: ImapCredentials = { + host: settings.imapServer, + port: settings.imapPort, + user: stressfreiEmail.email, + password, + encryption: settings.imapEncryption, + allowSelfSignedCerts: settings.allowSelfSignedCerts, + }; + + // Ordner basierend auf E-Mail-Typ bestimmen (INBOX oder Sent) + const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX'; + + // Anhang per IMAP abrufen + const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder); + + if (!attachment) { + res.status(404).json({ + success: false, + error: 'Anhang nicht gefunden', + } as ApiResponse); + return; + } + + // Ziel-Konfiguration finden + let targetConfig; + let targetDir: string; + let targetField: string; + + console.log('[saveAttachmentTo] Looking for target config:', { entityType, targetKey }); + + if (entityType === 'customer') { + targetConfig = documentTargets.customer.find(t => t.key === targetKey); + } else if (entityType === 'identityDocument') { + targetConfig = documentTargets.identityDocument.find(t => t.key === targetKey); + } else if (entityType === 'bankCard') { + targetConfig = documentTargets.bankCard.find(t => t.key === targetKey); + } else if (entityType === 'contract') { + targetConfig = documentTargets.contract.find(t => t.key === targetKey); + } + + console.log('[saveAttachmentTo] Found targetConfig:', targetConfig); + + if (!targetConfig) { + res.status(400).json({ + success: false, + error: `Unbekanntes Dokumentziel: ${entityType}/${targetKey}`, + } as ApiResponse); + return; + } + + targetDir = targetConfig.directory; + targetField = targetConfig.field; + + // Uploads-Verzeichnis erstellen + const uploadsDir = path.join(process.cwd(), 'uploads', targetDir); + if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + } + + // Eindeutigen Dateinamen generieren + const ext = path.extname(filename) || '.pdf'; + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const newFilename = `${uniqueSuffix}${ext}`; + const filePath = path.join(uploadsDir, newFilename); + const relativePath = `/uploads/${targetDir}/${newFilename}`; + + // Datei speichern + fs.writeFileSync(filePath, attachment.content); + + // Alte Datei löschen und DB aktualisieren + if (entityType === 'customer') { + // Customer ID aus StressfreiEmail laden + console.log('[saveAttachmentTo] Looking up customer for stressfreiEmail.id:', stressfreiEmail.id); + const customer = await prisma.customer.findFirst({ + where: { + stressfreiEmails: { some: { id: stressfreiEmail.id } }, + }, + }); + console.log('[saveAttachmentTo] Found customer:', customer?.id); + + if (!customer) { + fs.unlinkSync(filePath); + res.status(404).json({ + success: false, + error: 'Kunde nicht gefunden', + } as ApiResponse); + return; + } + + // Alte Datei löschen + const oldPath = (customer as any)[targetField]; + if (oldPath) { + const oldFullPath = path.join(process.cwd(), oldPath); + if (fs.existsSync(oldFullPath)) { + fs.unlinkSync(oldFullPath); + } + } + + await prisma.customer.update({ + where: { id: customer.id }, + data: { [targetField]: relativePath }, + }); + } else if (entityType === 'identityDocument') { + if (!entityId) { + fs.unlinkSync(filePath); + res.status(400).json({ + success: false, + error: 'entityId ist für identityDocument erforderlich', + } as ApiResponse); + return; + } + + const doc = await prisma.identityDocument.findUnique({ where: { id: entityId } }); + if (!doc) { + fs.unlinkSync(filePath); + res.status(404).json({ + success: false, + error: 'Ausweis nicht gefunden', + } as ApiResponse); + return; + } + + // Alte Datei löschen + const oldPath = (doc as any)[targetField]; + if (oldPath) { + const oldFullPath = path.join(process.cwd(), oldPath); + if (fs.existsSync(oldFullPath)) { + fs.unlinkSync(oldFullPath); + } + } + + await prisma.identityDocument.update({ + where: { id: entityId }, + data: { [targetField]: relativePath }, + }); + } else if (entityType === 'bankCard') { + if (!entityId) { + fs.unlinkSync(filePath); + res.status(400).json({ + success: false, + error: 'entityId ist für bankCard erforderlich', + } as ApiResponse); + return; + } + + const card = await prisma.bankCard.findUnique({ where: { id: entityId } }); + if (!card) { + fs.unlinkSync(filePath); + res.status(404).json({ + success: false, + error: 'Bankkarte nicht gefunden', + } as ApiResponse); + return; + } + + // Alte Datei löschen + const oldPath = (card as any)[targetField]; + if (oldPath) { + const oldFullPath = path.join(process.cwd(), oldPath); + if (fs.existsSync(oldFullPath)) { + fs.unlinkSync(oldFullPath); + } + } + + await prisma.bankCard.update({ + where: { id: entityId }, + data: { [targetField]: relativePath }, + }); + } else if (entityType === 'contract') { + // Contract-ID kommt aus der E-Mail-Zuordnung + if (!email.contractId) { + fs.unlinkSync(filePath); + res.status(400).json({ + success: false, + error: 'E-Mail ist keinem Vertrag zugeordnet', + } as ApiResponse); + return; + } + + const contract = await prisma.contract.findUnique({ where: { id: email.contractId } }); + if (!contract) { + fs.unlinkSync(filePath); + res.status(404).json({ + success: false, + error: 'Vertrag nicht gefunden', + } as ApiResponse); + return; + } + + // Alte Datei löschen + const oldPath = (contract as any)[targetField]; + if (oldPath) { + const oldFullPath = path.join(process.cwd(), oldPath); + if (fs.existsSync(oldFullPath)) { + fs.unlinkSync(oldFullPath); + } + } + + await prisma.contract.update({ + where: { id: email.contractId }, + data: { [targetField]: relativePath }, + }); + } + + res.json({ + success: true, + data: { + path: relativePath, + filename: newFilename, + originalName: filename, + size: attachment.size, + }, + } as ApiResponse); + } catch (error) { + console.error('saveAttachmentTo error:', error); + // Detailliertere Fehlermeldung für Debugging + const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler'; + res.status(500).json({ + success: false, + error: `Fehler beim Speichern des Anhangs: ${errorMessage}`, + } as ApiResponse); + } +} diff --git a/backend/src/routes/cachedEmail.routes.ts b/backend/src/routes/cachedEmail.routes.ts index 715d2b13..49ad1a36 100644 --- a/backend/src/routes/cachedEmail.routes.ts +++ b/backend/src/routes/cachedEmail.routes.ts @@ -158,6 +158,24 @@ router.get( cachedEmailController.downloadAttachment ); +// Verfügbare Dokumenten-Ziele für Anhänge (zum Speichern) +// GET /api/emails/:id/attachment-targets +router.get( + '/emails/:id/attachment-targets', + authenticate, + requirePermission('customers:read'), + cachedEmailController.getAttachmentTargets +); + +// Anhang in Dokumentenfeld speichern +// POST /api/emails/:id/attachments/:filename/save-to { entityType, entityId?, targetKey } +router.post( + '/emails/:id/attachments/:filename/save-to', + authenticate, + requirePermission('customers:update'), + cachedEmailController.saveAttachmentTo +); + // ==================== VERTRAGSZUORDNUNG ==================== // E-Mail Vertrag zuordnen diff --git a/backend/src/services/imapService.ts b/backend/src/services/imapService.ts index f44cb30b..2c1f6a1f 100644 --- a/backend/src/services/imapService.ts +++ b/backend/src/services/imapService.ts @@ -163,12 +163,12 @@ export async function fetchEmails( return []; } - // E-Mails abrufen - for await (const message of client.fetch(limitedUids, { - uid: true, + // E-Mails abrufen - drittes Argument { uid: true } für UID FETCH + for await (const message of client.fetch(limitedUids.join(','), { + uid: true, // UID im Response inkludieren envelope: true, source: true, // Vollständige E-Mail für Parsing - })) { + }, { uid: true })) { try { // Source muss vorhanden sein if (!message.source) { @@ -389,10 +389,10 @@ export async function fetchAttachment( // E-Mail per UID abrufen let attachment: EmailAttachmentData | null = null; - for await (const message of client.fetch([uid], { - uid: true, + // Drittes Argument { uid: true } sorgt dafür, dass UID FETCH statt FETCH verwendet wird + for await (const message of client.fetch(uid.toString(), { source: true, - })) { + }, { uid: true })) { if (!message.source) continue; // E-Mail parsen @@ -555,10 +555,10 @@ export async function fetchAttachmentList( await client.connect(); await client.mailboxOpen(folder); - for await (const message of client.fetch([uid], { - uid: true, + // Drittes Argument { uid: true } für UID FETCH + for await (const message of client.fetch(uid.toString(), { source: true, - })) { + }, { uid: true })) { if (!message.source) continue; const parsed = await simpleParser(message.source); diff --git a/backend/uploads/business-registrations/1770159143387-113118573.pdf b/backend/uploads/business-registrations/1770159143387-113118573.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fe6584da7104aabf88494d00344d2ece1703eab6 GIT binary patch literal 8669 zcmcI}cT`hb(>_%tpa=*^4@hq*gd`xngGfhuCqN(&O6WzTs|W%Lf(TNi2&hQ!2+~1N zdR2NaBE9~C@4fGP?|0vCeSiJVT03W-*?VU8%sg{W)_Sxo%Ecb%02IMXbU;dI3=ZWEQo>l_Pzop;tSw4P3gCfrM_D-od{RXp z6$6N5DM*h46oV{>lP2q)kh8n;7>|%Gf7U1+YW;3N8H0|ndU|J;vhJq9CXbM;Zf~f* z30=(XO^qfUB&$JaWs_Dd<6E9XUk~l(IrPoyToTj9oKf-|jFJb%i>N=;-sG)5BN3hF$59WC0uzkKaQWP+RL{JrQN+-05M}V&9MnKsiYG&eGqY zOuEG?Q;QZ9Uz4s}TL}29$u1mojExSz@rro{q$M%KMtL^e{9WFte>}{V$NH-RROVVQ zmCi!WiJ`>J)gad;)MuNpt+u#J2lA9xLl|x>`h-K}pFc0^=-@hMqny{)Q4xR66qWdS z#PjPXX+;wE_@`C-yk7I-6CT{?$-TzdSkI2%|(0jrCdCmo2oYd@YI@ph= ziu8RTr@|h4_qF*5>GNpYFQ=>})!`WSbe@;(BScT=e^5!09}jD?eoCotci4mJ4t8u# z*LS!jZ7R+^w!NV#ZMiX_9n_zzw{$;ni2oq2aa{2+&$4aIsc|!YAJeKoJp5MR4F4&(HnJgm;PA3XK#iWE_W7qr zIuUa~9n)w=L4L-f5#g%6@SL&0o43^M?aRiDLyZRYZN&wcK4HSL^~*=U&0+ z_A~bA6|!{QonVEzd2Jlt;_mQFGWd2V_hCpNcVlHee!F6GN5%Bv9g4%akaJ^9%iSF7 z)7t3c{t=R{kd{vXdis7Sv)C$7K1wBFT$0a}Q$EK&s@Z(`Ig)L@J6kNA_?bxZLU+M2 zW@P-GRPVEF-cjZcWi~Cb2X8vqQ{PFJLg25S9mnO5z@O*Rzg#!x7O>WPNo=L9lV}~M z<&;76<*I)2Hwfo`*1&ou8OyVkU!U!mQZl^8oY$oezt=ydx*BD;^rdqExZ}^M%tUmp zVWN0%+4#2OLEy*-S0<+%+lrQKqL&`^HL$`x*YwEzc1IoA0)>F)kBPQe`&Bhe`gYkH zx!2{Gamn7eqblFh^y8tWsdlX*azUx9`K=&* z=%DF_{y7Imw)lg3_RUdr7@e-WehM9(!HVU9#8_2WomdZD?m+pwdOpVqZYIu~hjtJ9 zsJvjycQK&X$g?tal5w* z3S6B$G{%2y?V&F-Z;Vg-YD8{^M;=S|x;B;_rA{qBZ~rC1Gb10WEvEKH;Zf9$j6^>w zZ4uvok85(=3bgJu;$eTD@OJwHG+=8xY$=;;uw2W|^W?WUeGdp3t9sO;^pDnu|IA3MHkf zo8*UP52a8|dzzZeTRf7krqS#exYWI|76O)MFY7Y+(vyVY){R3yq@OmHQ;pk>M?J80 z$QCr|<3o+7KAVzT7UJ`}^rpa7;S)ig)WbUOJ2E_$xw$u!vMbjv=kQG`j`4w9_;sr- z^le(ywrZvo11<{%@5cP{HHGIckY8oYj8Hc(`ZDkR^f|MU!)d+HBYERg(K`Vk1K=DD%k@+&)e)%AMqfcDI4?e zW>2-hJMzm4blu=M9613Z4?xpc9u!spI2T4O8DRB1?;bdRBY#HRR8+;vQTvM!FCpSA$?f zLQR84OVS-ZnQG1KH)#>(z1G-zYWS0CAaSUWQy~aCFf0lm$%t_J+`jq<;EZ|$r>Jk# zvYoDC)4V^qJ2(aMr-mcCb{rO)3aoQOjLzQ*OZ!tna z6{{8g52vYasVF%>LMByoqjGce>(hE@BGQLV4V0bUnQD*aRfv|$1X=}V+_eZ6>)URI zfB$i}g2W`~sNb{9=@?T%`&qflzo2U>pyYfzWk{t)8b>}_r95Yd?XJt8-K z67qSa?^v0;fXTQk40t(74@q4>>F@RDtLsMj!O5~#5tKb!&LR_b5!nnxc6qR_ox07G zX&yVD4vOBCX({dpYwt+KEo^pPspkfNwNUNZ5JI3x0@v7HF zT$8$8KT*7YIbF)Y-E&oZSF7IdY)w`jk=o6|HBntOD9 zqV4AC`E!C9GMyszzPm@@P5P)GZBU$K_-V%>-Q`EynIVPLc^FLgskQq-3l?bMP7z-!8RnXsG_J+sN8O53|--9cKd9t zv<2a4;+0I!iBhW6NfF?xVlN|ZZ+XGiY8U8tnYZz0f08I&Kj7I{GZ})hhcgi^Gr34I4qI$F%pIjNl+;`G0_3(dI#rg8ar|}rEyR04U zUDuEDFVLt>=t2eLZ~Q{a zs;)l$k$|4mhezmqmvnm;?JFCsPS;gKbfOTPL~@Wr5KHbFOHe^}o!(Ls_^QqHP{WQ@rB9t8Et@I7=GwuD2~2qTNm3DO zs5@czI{Iy%VNH2vCVEd#SMl)Z35xdaBOZaF=~p_#MWD9ZR>uZn*Sd)h0(gPrEQ5Z} z0k9M~`G~Y7Pd{OU{KYDwSHf-UgJ}H}!3paUz~#|jEEe40y+dW#tED5qfVAa)6Q>ol zoq$PPhFTFyykC(iBhCM{yYnRdO1;GDL<8G4bhx29?Y=ME8fxkV7#t)50S0M2Yjk>- zHB1W-TOewhdeJAhjdxb!sl)TWUIEk>mf9)~Jz6)n$uMtVW*hY@V8#DPYTGmD0ZiM& zv-{S$sym=J1&NgzfUbf3mK>2UhXtkYtNscRQUytWU7-la5L(~ey~>n9%E+WPdaN|d zs5L)hlOmNp{p4~35_0<`Tl(4LLO&(*`Uv%s$=4m;rzV>>C5GUpZ4C#A^oD>Mt-9OS zS|<_CGqn3h-lII`Wj)3>Iwz?*9J6;TXqwK6RvS{gkuL0qhpm_=N+`W#p)S%2?Ehyl1S@4>R&wTfzV%EyLFgamRvBOJy2~PU##r88?0&IT&gZ z3b6l#cs?uyJ{K3!v_N7*5rPb=htBHhQza{-8bPR8>-E-+PS0kqem@}x$x`RjqkzA9n0FNSx0W|BRBV)@jwqhZ%CORT*p_&EL;08Mm;A>#DK#Igr+ zV980ewl415Ph7CtC{*G%JxARwqkzOb`r)d&Obv_0y_F>C)v$hz%3iVr4Ha?rA7Mc} zrD7SM$OK+#{U8aa5k^k)XuNPE0!X)pVNC>6<2f}B`p7n+(@AvXm!+&qd^478&>7+N zwCUGhcrRN*Gii`49{wH6hO$`!uN3);pT(3sv)0%W%Sa>xO@&;RMvk=QgGAWP$@)ngUnJ>DW-e;#M5bJ&FTR2(_ zHTHKN?wR;36zS5y9yIQZpx*PorGXTy-Y55_bR0f^V5ob)XXhUv5!y905>+?g<)B8j+Sxp}*^Qd^pLk+5w?hA&)Q2UlT!q^Dg?x+k7c3I)9_ zXm^}x_}vKKk4nYH5wyG_{LN|~)!lL_?5{tb>4Fw=ufYllNL9v{?YW%gW=1} z)V(w9h!WL=qc&(EdkwS%g^XP5P(SYEx|)nF4OedN*!pBCLvfH0w%?dAzhN1V9Wcftu#0C3yjSAeZs5s;HlS7t)Ux~ByFcZ>#T9M zZp_vaNJb6LP&dwqTQf3uEuJjx@r1@@FU694KJ#r!i;LTGeM%J>`ZJp?JgeinQnft? zs|HC^^MFaFozw%;!KFKw_DNfc&TOGCqLfn4NRFD?kDB8gGSw#70F;rRPtU?`Ug2)a zqO!fla-DuPs(sf+I8Ft}mNG$K*i@-J^GSi1nSquDhB7iyk2q0H+3^80P*NVZwO+IA zYPHD|4U-&W(}?K%=>ukWA>W|)&ze1JPY})5`T)OWs0)!`eXP7+e6xV2`KKi~oK*}|@-tK3r-Q+&Fl>G~-{LI6 zGLqIK$00U(VKHr)U+ZA$aSqJHS2^Ve$vM~_D#hn9(w;sb;Eox5bWJn5U&<;uVfn{N z^H1JwYnwApV+PHEu=tJ<nqQ*mTbrLuQ)56T9x)a@sn>ib1>ixjHYgNPIQckCyQ>5eRh}g zfwAl}GB8=3-t1u#=7j64j6$v3V+H(f&YM!mAy;-ut zo@x5j6Z9Yz*i6*TUnUt$9jM;f14DDgcbkN%?pn0iQ2H^;o>~ocl+J-m?(%S;#2&e- zyjx~1^iEa0Zt1r?LA`XCz*2dH*(qMygQS}{<*d2=44OUr(w zbL!{;h*CcHI!1WOkFry=?%MvtjRIqJeb;Hsx3uVD9~yFuvSI0iLuTE<937jFxq7WO z80oRw-_xn!*!)MAUX-n9<{w@uxT~|rh@v9h;u-uofKk-;4%xj`mZsC6dZI9YQdqBT z1Zwqqlg-?PG+|?hKY)Csvk+Xr$#R-+ouGh3@ue-%4I|mZ&6W@|xSypHz z7R}LcSrXUp?cmitsX>%}!X^}^ZeNYKD*Kcm%<|dXY-D9DCR7$m@Us1q>yOC?5I3qm>9Q`Md4&9ZqWyHs?a)~^ z*M=HBm%yWMRd-B#k3%=5Po*r5r5wIr7Ol#wS*l;Exmn|LWUZ9AC)xM(d&pT5eM;db z1}VLTWtw6IE<#Fvl`T##^7P(We7^XA&F3Y;&{JeaeO(=6=f1F;OfHgSH<0jM?2KiD z&HRH&8OYG$N1?sn7iRJ1<1~VGnjs$KzI=UT4;%52~G5%%CV<;Cv+jLL;&SL|CG%pGNt>_tF3eA%RSvUOS zk{9e}s71({zxux3lt;g$@`iTn#*5n71D2Dy^)~Ec+LX5oTas&%NmiViQ+8Gp{{Yiv zz1C91>&|}LGxT4RJQ=MbkFhj>7>`qrL#x8SuklxSdBKdzScb0ow>hzF z;%dLgK}HB;e@GAazw;2Dn&z*lVHUnq?Btt^Zq%|szP`L3{LcJ#BO$yxjbfzC!&sw$ zWq|eDw(&Q)N>yUsX+h@njm?6>HSvO!)%Ey+jaSsniE2kngT;Z7&)f_-h;MuOZ>{=>cK`MRP&N>~@10?Nb29qo$4;wdEjqlT3;3M40caS5oStx@h) zI5gHp7v+w&16*)S_8veOeuCdACx`Vh5fX)o0EI-rB0vZffdGmjA!Z;|oD~LbBkN+1 zK>@)4kgSId$^~~ZyKwc#RNl%}1%SK)N`T^F1IEzvsd-p3y=I`p`(oM(1*^h7HT;@L zPI3u}GCfv0Dt8~BGC3m|?jg|3*J$FkPWJ|H_}-(si& zr1m#fbhzTW_jssPlBmlAXm(r7UTdE45B3X6X~kBv0=FK`k2Pj3H>43Ms;HSPi? zdhVX6-%>d%57b40{&#?&-92#f4p#0L@ z3&=o4!9Woa5uhmeU-yf(i}|0!>|dDp4@`j=PZ81JIW4)4-_97{zM9B4_AzpuL9Oa4~@fjISvny zi$vi|?qbhtixN^$cyyQslzq#r@A` zDBd)_?6#gZsQ)jve;*tF3RS>YoL4~{3WmbL5D_p020@6z1i%m;Fqnt$FIDluXBq$o zLSO)V;D1~|xTvUzD9{f0hYTT#z-RR00=oPu1A`%Woc|9QL=4{>f0rTPNIWL~E(1d$ z|JVmdK>x81j)37Y^LJgin8-h52q+Q{#=q}Fz>xTF(*KZ&{?ji4@n3Y2f7QjgTj4(t z?ia10i}pj|Q-yCREFRmxdk6qhb+N+&As3LtceJVk&;%$UCJL7mLBhfEqKZ%@StU6b z6rWL11Re-5IT#!+CI$Gvw_G$49`W)HC>tjaPiG)V9tMUfDnXG*B{`@FSY8PsE3YJ{ jpeP~+5tWB3D9B%QfCtXX9rwGWNC*-R;Nnu$R08}z-en#* literal 0 HcmV?d00001 diff --git a/backend/uploads/privacy-policies/1770159060770-214871653.pdf b/backend/uploads/privacy-policies/1770159060770-214871653.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fe6584da7104aabf88494d00344d2ece1703eab6 GIT binary patch literal 8669 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/email/EmailDetail.tsx b/frontend/src/components/email/EmailDetail.tsx index a9475f11..5f417f70 100644 --- a/frontend/src/components/email/EmailDetail.tsx +++ b/frontend/src/components/email/EmailDetail.tsx @@ -1,11 +1,12 @@ import { useState, useEffect } from 'react'; -import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2 } from 'lucide-react'; +import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save } from 'lucide-react'; import { CachedEmail, cachedEmailApi } from '../../services/api'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import Button from '../ui/Button'; import { Link } from 'react-router-dom'; import { useAuth } from '../../context/AuthContext'; import toast from 'react-hot-toast'; +import SaveAttachmentModal from './SaveAttachmentModal'; interface EmailDetailProps { email: CachedEmail; @@ -35,6 +36,7 @@ export default function EmailDetail({ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showRestoreConfirm, setShowRestoreConfirm] = useState(false); const [showPermanentDeleteConfirm, setShowPermanentDeleteConfirm] = useState(false); + const [saveAttachmentFilename, setSaveAttachmentFilename] = useState(null); const queryClient = useQueryClient(); const { hasPermission } = useAuth(); @@ -327,6 +329,16 @@ export default function EmailDetail({ > + {/* Speichern-Button (nicht im Papierkorb) */} + {!isTrashView && ( + + )} ))} @@ -459,6 +471,16 @@ export default function EmailDetail({ )} + + {/* Anhang speichern Modal */} + {saveAttachmentFilename && ( + setSaveAttachmentFilename(null)} + emailId={email.id} + attachmentFilename={saveAttachmentFilename} + /> + )} ); } diff --git a/frontend/src/components/email/SaveAttachmentModal.tsx b/frontend/src/components/email/SaveAttachmentModal.tsx new file mode 100644 index 00000000..7a5100da --- /dev/null +++ b/frontend/src/components/email/SaveAttachmentModal.tsx @@ -0,0 +1,295 @@ +import { useState } from 'react'; +import { FileText, User, CreditCard, IdCard, AlertTriangle, Check, ChevronDown, ChevronRight } from 'lucide-react'; +import Modal from '../ui/Modal'; +import Button from '../ui/Button'; +import { cachedEmailApi, AttachmentTargetSlot, AttachmentEntityWithSlots } from '../../services/api'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; + +interface SaveAttachmentModalProps { + isOpen: boolean; + onClose: () => void; + emailId: number; + attachmentFilename: string; + onSuccess?: () => void; +} + +type SelectedTarget = { + entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract'; + entityId?: number; + targetKey: string; + hasDocument: boolean; + label: string; +}; + +export default function SaveAttachmentModal({ + isOpen, + onClose, + emailId, + attachmentFilename, + onSuccess, +}: SaveAttachmentModalProps) { + const [selectedTarget, setSelectedTarget] = useState(null); + const [expandedSections, setExpandedSections] = useState>(new Set(['customer'])); + const queryClient = useQueryClient(); + + // Ziele laden + const { data: targetsData, isLoading, error } = useQuery({ + queryKey: ['attachment-targets', emailId], + queryFn: () => cachedEmailApi.getAttachmentTargets(emailId), + enabled: isOpen, + }); + + const targets = targetsData?.data; + + const saveMutation = useMutation({ + mutationFn: () => { + if (!selectedTarget) throw new Error('Kein Ziel ausgewählt'); + return cachedEmailApi.saveAttachmentTo(emailId, attachmentFilename, { + entityType: selectedTarget.entityType, + entityId: selectedTarget.entityId, + targetKey: selectedTarget.targetKey, + }); + }, + onSuccess: () => { + toast.success('Anhang gespeichert'); + queryClient.invalidateQueries({ queryKey: ['attachment-targets', emailId] }); + queryClient.invalidateQueries({ queryKey: ['customers'] }); + queryClient.invalidateQueries({ queryKey: ['contracts'] }); + + // Spezifische Ansichten aktualisieren (IDs als String, da URL-Params Strings sind) + if (targets?.customer?.id) { + queryClient.invalidateQueries({ queryKey: ['customer', targets.customer.id.toString()] }); + } + if (targets?.contract?.id) { + queryClient.invalidateQueries({ queryKey: ['contract', targets.contract.id.toString()] }); + } + + onSuccess?.(); + handleClose(); + }, + onError: (error: Error) => { + toast.error(error.message || 'Fehler beim Speichern'); + }, + }); + + const handleClose = () => { + setSelectedTarget(null); + onClose(); + }; + + const toggleSection = (section: string) => { + const newExpanded = new Set(expandedSections); + if (newExpanded.has(section)) { + newExpanded.delete(section); + } else { + newExpanded.add(section); + } + setExpandedSections(newExpanded); + }; + + const handleSelectSlot = ( + entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract', + slot: AttachmentTargetSlot, + entityId?: number, + parentLabel?: string + ) => { + setSelectedTarget({ + entityType, + entityId, + targetKey: slot.key, + hasDocument: slot.hasDocument, + label: parentLabel ? `${parentLabel} → ${slot.label}` : slot.label, + }); + }; + + const renderSlots = ( + slots: AttachmentTargetSlot[], + entityType: 'customer' | 'identityDocument' | 'bankCard' | 'contract', + entityId?: number, + parentLabel?: string + ) => { + return slots.map((slot) => { + const isSelected = + selectedTarget?.entityType === entityType && + selectedTarget?.entityId === entityId && + selectedTarget?.targetKey === slot.key; + + return ( +
handleSelectSlot(entityType, slot, entityId, parentLabel)} + className={` + flex items-center gap-3 p-3 cursor-pointer transition-colors rounded-lg ml-4 + ${isSelected ? 'bg-blue-100 ring-2 ring-blue-500' : 'hover:bg-gray-100'} + `} + > +
+
+ {slot.label} + {slot.hasDocument && ( + + + Vorhanden + + )} +
+
+ {isSelected && } +
+ ); + }); + }; + + const renderEntityWithSlots = ( + entity: AttachmentEntityWithSlots, + entityType: 'identityDocument' | 'bankCard' + ) => { + return ( +
+
+ {entity.label} +
+ {renderSlots(entity.slots, entityType, entity.id, entity.label)} +
+ ); + }; + + const renderSection = ( + title: string, + sectionKey: string, + icon: React.ReactNode, + children: React.ReactNode, + isEmpty: boolean = false + ) => { + const isExpanded = expandedSections.has(sectionKey); + + return ( +
+ + {isExpanded && ( +
+ {isEmpty ? ( +

Keine Einträge vorhanden

+ ) : ( + children + )} +
+ )} +
+ ); + }; + + return ( + +
+ {/* Attachment Info */} +
+

+ Datei: {attachmentFilename} +

+
+ + {/* Loading */} + {isLoading && ( +
+
+
+ )} + + {/* Error */} + {error && ( +
+ Fehler beim Laden der Dokumentziele +
+ )} + + {/* Targets */} + {targets && ( +
+ {/* Kunde */} + {renderSection( + `Kunde: ${targets.customer.name}`, + 'customer', + , + renderSlots(targets.customer.slots, 'customer'), + targets.customer.slots.length === 0 + )} + + {/* Ausweise */} + {renderSection( + 'Ausweisdokumente', + 'identityDocuments', + , + targets.identityDocuments.map((doc) => + renderEntityWithSlots(doc, 'identityDocument') + ), + targets.identityDocuments.length === 0 + )} + + {/* Bankkarten */} + {renderSection( + 'Bankkarten', + 'bankCards', + , + targets.bankCards.map((card) => renderEntityWithSlots(card, 'bankCard')), + targets.bankCards.length === 0 + )} + + {/* Vertrag */} + {targets.contract && renderSection( + `Vertrag: ${targets.contract.contractNumber}`, + 'contract', + , + renderSlots(targets.contract.slots, 'contract'), + targets.contract.slots.length === 0 + )} + + {!targets.contract && ( +
+ + E-Mail ist keinem Vertrag zugeordnet. Ordnen Sie die E-Mail einem Vertrag zu, um + Vertragsdokumente als Ziel auswählen zu können. +
+ )} +
+ )} + + {/* Warning if replacing */} + {selectedTarget?.hasDocument && ( +
+ +
+ Achtung: An diesem Feld ist bereits ein Dokument hinterlegt. Das + vorhandene Dokument wird durch den neuen Anhang ersetzt. +
+
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 615714a3..ca1deb16 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -293,6 +293,37 @@ export interface SendEmailParams { contractId?: number; // Vertrag dem die gesendete E-Mail zugeordnet wird } +// Anhang-Speicher-Ziele +export interface AttachmentTargetSlot { + key: string; + label: string; + field: string; + hasDocument: boolean; + currentPath?: string; +} + +export interface AttachmentEntityWithSlots { + id: number; + label: string; + slots: AttachmentTargetSlot[]; +} + +export interface AttachmentTargetsResponse { + customer: { + id: number; + name: string; + type: 'PRIVATE' | 'BUSINESS'; + slots: AttachmentTargetSlot[]; + }; + identityDocuments: AttachmentEntityWithSlots[]; + bankCards: AttachmentEntityWithSlots[]; + contract?: { + id: number; + contractNumber: string; + slots: AttachmentTargetSlot[]; + }; +} + export const stressfreiEmailApi = { getByCustomer: async (customerId: number, includeInactive = false) => { const res = await api.get>(`/customers/${customerId}/stressfrei-emails`, { params: { includeInactive } }); @@ -460,6 +491,21 @@ export const cachedEmailApi = { const res = await api.delete>(`/emails/${emailId}/permanent`); return res.data; }, + // ==================== ANHANG-SPEICHERUNG ==================== + // Verfügbare Dokumenten-Ziele für Anhänge abrufen + getAttachmentTargets: async (emailId: number) => { + const res = await api.get>(`/emails/${emailId}/attachment-targets`); + return res.data; + }, + // Anhang in Dokumentenfeld speichern + saveAttachmentTo: async (emailId: number, filename: string, params: { entityType: string; entityId?: number; targetKey: string }) => { + const encodedFilename = encodeURIComponent(filename); + const res = await api.post>( + `/emails/${emailId}/attachments/${encodedFilename}/save-to`, + params + ); + return res.data; + }, }; // Contracts