save attachment from email in customer data and - or contracts
This commit is contained in:
parent
11b75b8e94
commit
90565d5137
37
README.md
37
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<string, string> = {
|
||||
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<string, unknown>)[target.field],
|
||||
currentPath: ((doc as Record<string, unknown>)[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<string, unknown>)[target.field],
|
||||
currentPath: ((card as Record<string, unknown>)[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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
|
|
@ -327,6 +329,16 @@ export default function EmailDetail({
|
|||
>
|
||||
<Download className="w-4 h-4 text-gray-500" />
|
||||
</a>
|
||||
{/* Speichern-Button (nicht im Papierkorb) */}
|
||||
{!isTrashView && (
|
||||
<button
|
||||
onClick={() => setSaveAttachmentFilename(name)}
|
||||
className="p-1 hover:bg-blue-100 rounded transition-colors"
|
||||
title={`${name} speichern unter...`}
|
||||
>
|
||||
<Save className="w-4 h-4 text-blue-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -459,6 +471,16 @@ export default function EmailDetail({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Anhang speichern Modal */}
|
||||
{saveAttachmentFilename && (
|
||||
<SaveAttachmentModal
|
||||
isOpen={true}
|
||||
onClose={() => setSaveAttachmentFilename(null)}
|
||||
emailId={email.id}
|
||||
attachmentFilename={saveAttachmentFilename}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SelectedTarget | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(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 (
|
||||
<div
|
||||
key={slot.key}
|
||||
onClick={() => 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'}
|
||||
`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">{slot.label}</span>
|
||||
{slot.hasDocument && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-800">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Vorhanden
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <Check className="w-5 h-5 text-blue-600" />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const renderEntityWithSlots = (
|
||||
entity: AttachmentEntityWithSlots,
|
||||
entityType: 'identityDocument' | 'bankCard'
|
||||
) => {
|
||||
return (
|
||||
<div key={entity.id} className="mb-2">
|
||||
<div className="text-sm font-medium text-gray-700 px-3 py-1 bg-gray-50 rounded">
|
||||
{entity.label}
|
||||
</div>
|
||||
{renderSlots(entity.slots, entityType, entity.id, entity.label)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSection = (
|
||||
title: string,
|
||||
sectionKey: string,
|
||||
icon: React.ReactNode,
|
||||
children: React.ReactNode,
|
||||
isEmpty: boolean = false
|
||||
) => {
|
||||
const isExpanded = expandedSections.has(sectionKey);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection(sectionKey)}
|
||||
className="w-full flex items-center gap-2 p-3 bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
{icon}
|
||||
<span className="font-medium text-gray-900">{title}</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="p-2">
|
||||
{isEmpty ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">Keine Einträge vorhanden</p>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Anhang speichern unter" size="lg">
|
||||
<div className="space-y-4">
|
||||
{/* Attachment Info */}
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Datei:</span> {attachmentFilename}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 text-red-700 rounded-lg">
|
||||
Fehler beim Laden der Dokumentziele
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Targets */}
|
||||
{targets && (
|
||||
<div className="space-y-3 max-h-96 overflow-auto">
|
||||
{/* Kunde */}
|
||||
{renderSection(
|
||||
`Kunde: ${targets.customer.name}`,
|
||||
'customer',
|
||||
<User className="w-4 h-4 text-blue-600" />,
|
||||
renderSlots(targets.customer.slots, 'customer'),
|
||||
targets.customer.slots.length === 0
|
||||
)}
|
||||
|
||||
{/* Ausweise */}
|
||||
{renderSection(
|
||||
'Ausweisdokumente',
|
||||
'identityDocuments',
|
||||
<IdCard className="w-4 h-4 text-green-600" />,
|
||||
targets.identityDocuments.map((doc) =>
|
||||
renderEntityWithSlots(doc, 'identityDocument')
|
||||
),
|
||||
targets.identityDocuments.length === 0
|
||||
)}
|
||||
|
||||
{/* Bankkarten */}
|
||||
{renderSection(
|
||||
'Bankkarten',
|
||||
'bankCards',
|
||||
<CreditCard className="w-4 h-4 text-purple-600" />,
|
||||
targets.bankCards.map((card) => renderEntityWithSlots(card, 'bankCard')),
|
||||
targets.bankCards.length === 0
|
||||
)}
|
||||
|
||||
{/* Vertrag */}
|
||||
{targets.contract && renderSection(
|
||||
`Vertrag: ${targets.contract.contractNumber}`,
|
||||
'contract',
|
||||
<FileText className="w-4 h-4 text-orange-600" />,
|
||||
renderSlots(targets.contract.slots, 'contract'),
|
||||
targets.contract.slots.length === 0
|
||||
)}
|
||||
|
||||
{!targets.contract && (
|
||||
<div className="p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||
<FileText className="w-4 h-4 inline-block mr-2 text-gray-400" />
|
||||
E-Mail ist keinem Vertrag zugeordnet. Ordnen Sie die E-Mail einem Vertrag zu, um
|
||||
Vertragsdokumente als Ziel auswählen zu können.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning if replacing */}
|
||||
{selectedTarget?.hasDocument && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-yellow-800">
|
||||
<strong>Achtung:</strong> An diesem Feld ist bereits ein Dokument hinterlegt. Das
|
||||
vorhandene Dokument wird durch den neuen Anhang ersetzt.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!selectedTarget || saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? 'Wird gespeichert...' : 'Speichern'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ApiResponse<StressfreiEmail[]>>(`/customers/${customerId}/stressfrei-emails`, { params: { includeInactive } });
|
||||
|
|
@ -460,6 +491,21 @@ export const cachedEmailApi = {
|
|||
const res = await api.delete<ApiResponse<void>>(`/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<ApiResponse<AttachmentTargetsResponse>>(`/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<ApiResponse<{ path: string; filename: string; originalName: string; size: number }>>(
|
||||
`/emails/${emailId}/attachments/${encodedFilename}/save-to`,
|
||||
params
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Contracts
|
||||
|
|
|
|||
Loading…
Reference in New Issue