save attachment from email in customer data and - or contracts

This commit is contained in:
2026-02-03 23:58:00 +01:00
parent 30103e6099
commit b87053760e
8 changed files with 1037 additions and 11 deletions
@@ -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);
}
}
+18
View File
@@ -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
+10 -10
View File
@@ -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);