6b1d493f0b
Bisher lief documentType nur durch stripHtml – ein beliebiger String
("NICHT_ERLAUBT", "DROP TABLE ...", Tippfehler) wurde 1:1 als
ContractDocument.documentType in die DB geschrieben. Das brach
Frontend-Filter, Lieferbestätigung-Auto-Activation und Reports.
Neuer validateContractDocumentType-Helper in utils/sanitize:
- Whitelist ALLOWED_CONTRACT_DOCUMENT_TYPES (8 Werte, gespiegelt aus
Frontend CONTRACT_DOCUMENT_TYPES)
- Case-insensitiver Match, Rückgabe ist immer der kanonische Wert
- Wirft sprechende 400-Fehlermeldung mit Liste der erlaubten Werte
Eingesetzt in allen 3 Schreibpfaden:
- contract.controller.uploadContractDocument (multer-Datei wird bei
Reject sauber gelöscht)
- cachedEmail.controller.saveEmailAsContractDocument
- cachedEmail.controller.saveAttachmentAsContractDocument
Audit-Log + maybeActivateOnDeliveryConfirmation nutzen jetzt den
kanonischen Wert (statt der rohen Eingabe), damit Reports
einheitlich aussehen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2218 lines
74 KiB
TypeScript
2218 lines
74 KiB
TypeScript
// ==================== CACHED EMAIL CONTROLLER ====================
|
||
|
||
import { Request, Response } from 'express';
|
||
import * as cachedEmailService from '../services/cachedEmail.service.js';
|
||
import * as stressfreiEmailService from '../services/stressfreiEmail.service.js';
|
||
import * as invoiceService from '../services/invoice.service.js';
|
||
import { sendEmail, SmtpCredentials, SendEmailParams, EmailAttachment } from '../services/smtpService.js';
|
||
import { fetchAttachment, appendToSent, ImapCredentials } from '../services/imapService.js';
|
||
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
|
||
import { decrypt } from '../utils/encryption.js';
|
||
import { sanitizeNotes, stripHtml, validateContractDocumentType } from '../utils/sanitize.js';
|
||
import { logChange } from '../services/audit.service.js';
|
||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
||
import { generateEmailPdf } from '../services/pdfService.js';
|
||
import { maybeActivateOnDeliveryConfirmation, withContractDocumentLock } from '../services/contractStatusScheduler.service.js';
|
||
import { DocumentType } from '@prisma/client';
|
||
import prisma from '../lib/prisma.js';
|
||
import path from 'path';
|
||
import fs from 'fs';
|
||
import {
|
||
canAccessCustomer,
|
||
canAccessContract,
|
||
canAccessCachedEmail,
|
||
canAccessStressfreiEmail,
|
||
} from '../utils/accessControl.js';
|
||
|
||
// ==================== E-MAIL LIST ====================
|
||
|
||
// Hilfsfunktion: Query-Param zu boolean parsen ('true' / 'false' / fehlt).
|
||
function parseBoolParam(v: unknown): boolean | undefined {
|
||
if (v === 'true') return true;
|
||
if (v === 'false') return false;
|
||
return undefined;
|
||
}
|
||
|
||
function parseDateParam(v: unknown): Date | undefined {
|
||
if (typeof v !== 'string' || !v.trim()) return undefined;
|
||
const d = new Date(v);
|
||
return isNaN(d.getTime()) ? undefined : d;
|
||
}
|
||
|
||
// E-Mails für einen Kunden abrufen
|
||
export async function getEmailsForCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const customerId = parseInt(req.params.customerId);
|
||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||
const stressfreiEmailId = req.query.accountId ? parseInt(req.query.accountId as string) : undefined;
|
||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||
|
||
const emails = await cachedEmailService.getCachedEmails({
|
||
customerId,
|
||
stressfreiEmailId,
|
||
folder,
|
||
limit,
|
||
offset,
|
||
includeBody: false,
|
||
search: typeof req.query.search === 'string' ? req.query.search : undefined,
|
||
fromFilter: typeof req.query.fromFilter === 'string' ? req.query.fromFilter : undefined,
|
||
toFilter: typeof req.query.toFilter === 'string' ? req.query.toFilter : undefined,
|
||
subjectFilter: typeof req.query.subjectFilter === 'string' ? req.query.subjectFilter : undefined,
|
||
bodyFilter: typeof req.query.bodyFilter === 'string' ? req.query.bodyFilter : undefined,
|
||
attachmentNameFilter: typeof req.query.attachmentNameFilter === 'string' ? req.query.attachmentNameFilter : undefined,
|
||
hasAttachments: parseBoolParam(req.query.hasAttachments),
|
||
isRead: parseBoolParam(req.query.isRead),
|
||
isStarred: parseBoolParam(req.query.isStarred),
|
||
receivedFrom: parseDateParam(req.query.receivedFrom),
|
||
receivedTo: parseDateParam(req.query.receivedTo),
|
||
});
|
||
|
||
res.json({ success: true, data: emails } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('getEmailsForCustomer error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Laden der E-Mails',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// E-Mails für einen Vertrag abrufen
|
||
export async function getEmailsForContract(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const contractId = parseInt(req.params.contractId);
|
||
if (!(await canAccessContract(req, res, contractId))) return;
|
||
const folder = req.query.folder as string | undefined; // INBOX oder SENT
|
||
const limit = req.query.limit ? parseInt(req.query.limit as string) : 50;
|
||
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
|
||
|
||
const emails = await cachedEmailService.getCachedEmails({
|
||
contractId,
|
||
folder,
|
||
limit,
|
||
offset,
|
||
includeBody: false,
|
||
});
|
||
|
||
res.json({ success: true, data: emails } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('getEmailsForContract error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Laden der Vertrags-E-Mails',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// ==================== SINGLE EMAIL ====================
|
||
|
||
// Einzelne E-Mail abrufen (mit Body)
|
||
export async function getEmail(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const id = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||
|
||
const email = await cachedEmailService.getCachedEmailById(id);
|
||
|
||
if (!email) {
|
||
res.status(404).json({
|
||
success: false,
|
||
error: 'E-Mail nicht gefunden',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Als gelesen markieren
|
||
await cachedEmailService.markEmailAsRead(id);
|
||
|
||
res.json({ success: true, data: { ...email, isRead: true } } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('getEmail error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Laden der E-Mail',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// E-Mail als gelesen/ungelesen markieren
|
||
export async function markAsRead(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const id = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||
const { isRead } = req.body;
|
||
|
||
if (isRead) {
|
||
await cachedEmailService.markEmailAsRead(id);
|
||
} else {
|
||
await cachedEmailService.markEmailAsUnread(id);
|
||
}
|
||
|
||
res.json({ success: true } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('markAsRead error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Markieren der E-Mail',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// E-Mail Stern umschalten
|
||
export async function toggleStar(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const id = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||
const isStarred = await cachedEmailService.toggleEmailStar(id);
|
||
|
||
res.json({ success: true, data: { isStarred } } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('toggleStar error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Ändern des Sterns',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// ==================== CONTRACT ASSIGNMENT ====================
|
||
|
||
// E-Mail einem Vertrag zuordnen
|
||
export async function assignToContract(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const emailId = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||
const { contractId } = req.body;
|
||
if (!(await canAccessContract(req, res, contractId))) return;
|
||
const userId = (req as any).userId; // Falls Auth-Middleware userId setzt
|
||
|
||
const email = await cachedEmailService.assignEmailToContract(emailId, contractId, userId);
|
||
|
||
res.json({ success: true, data: email } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('assignToContract error:', error);
|
||
res.status(400).json({
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Fehler beim Zuordnen der E-Mail',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// Vertragszuordnung aufheben
|
||
export async function unassignFromContract(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const emailId = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||
|
||
const email = await cachedEmailService.unassignEmailFromContract(emailId);
|
||
|
||
res.json({ success: true, data: email } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('unassignFromContract error:', error);
|
||
res.status(400).json({
|
||
success: false,
|
||
error: error instanceof Error ? error.message : 'Fehler beim Aufheben der Zuordnung',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// E-Mail-Anzahl pro Ordner für ein Konto
|
||
export async function getFolderCounts(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const stressfreiEmailId = parseInt(req.params.id);
|
||
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
||
|
||
const counts = await cachedEmailService.getFolderCountsForAccount(stressfreiEmailId);
|
||
|
||
res.json({ success: true, data: counts } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('getFolderCounts error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Laden der Ordner-Anzahlen',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// E-Mail-Anzahl pro Ordner für einen Vertrag
|
||
export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const contractId = parseInt(req.params.contractId);
|
||
if (!(await canAccessContract(req, res, contractId))) return;
|
||
|
||
const counts = await cachedEmailService.getFolderCountsForContract(contractId);
|
||
|
||
res.json({ success: true, data: counts } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('getContractFolderCounts error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Laden der Ordner-Anzahlen',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// ==================== SYNC & SEND ====================
|
||
|
||
// E-Mails für ein Konto synchronisieren (INBOX + SENT)
|
||
export async function syncAccount(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const stressfreiEmailId = parseInt(req.params.id);
|
||
// Mitarbeiter brauchen customers:update (wie früher), Portal-Kunden
|
||
// brauchen keine Perm – nur Eigentum am Konto (Owner-Check unten).
|
||
// Trennung der Threat-Modelle: Portal-User dürfen IHR eigenes
|
||
// Postfach syncen, sollen aber nicht Mitarbeiter-Updates triggern.
|
||
const isPortal = !!req.user?.isCustomerPortal;
|
||
const hasUpdatePerm = req.user?.permissions?.includes('customers:update') ?? false;
|
||
if (!isPortal && !hasUpdatePerm) {
|
||
res.status(403).json({ success: false, error: 'Keine Berechtigung' } as ApiResponse);
|
||
return;
|
||
}
|
||
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
||
const fullSync = req.query.full === 'true';
|
||
|
||
// Synchronisiert sowohl INBOX als auch SENT
|
||
const result = await cachedEmailService.syncAllFoldersForAccount(stressfreiEmailId, { fullSync });
|
||
|
||
if (!result.success) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: result.error,
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
newEmails: result.newEmails,
|
||
totalEmails: result.totalEmails,
|
||
},
|
||
} as ApiResponse);
|
||
} catch (error) {
|
||
console.error('syncAccount error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Synchronisieren der E-Mails',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// Security: verhindert Header-Injection via CRLF in E-Mail-Feldern.
|
||
// nodemailer prüft das zwar auch selbst, aber besser vor dem Versand
|
||
// einen sauberen 400er zurückgeben als einen unklaren SMTP-Fehler.
|
||
function hasCRLF(value: unknown): boolean {
|
||
if (typeof value === 'string') return /[\r\n]/.test(value);
|
||
if (Array.isArray(value)) return value.some(hasCRLF);
|
||
return false;
|
||
}
|
||
|
||
// E-Mail senden
|
||
export async function sendEmailFromAccount(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const stressfreiEmailId = parseInt(req.params.id);
|
||
if (!(await canAccessStressfreiEmail(req, res, stressfreiEmailId))) return;
|
||
const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body;
|
||
|
||
// Header-Injection (CRLF) in Empfänger/Betreff ablehnen
|
||
if (hasCRLF(to) || hasCRLF(cc) || hasCRLF(subject) || hasCRLF(inReplyTo) || hasCRLF(references)) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'Ungültige Zeichen in E-Mail-Feldern (Zeilenumbrüche nicht erlaubt)',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// StressfreiEmail laden
|
||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||
|
||
if (!stressfreiEmail) {
|
||
res.status(404).json({
|
||
success: false,
|
||
error: 'E-Mail-Konto nicht gefunden',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
if (!stressfreiEmail.hasMailbox) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'Dieses Konto hat keine Mailbox für den Versand',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Passwort entschlüsseln
|
||
const password = await stressfreiEmailService.getDecryptedPassword(stressfreiEmailId);
|
||
|
||
if (!password) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'Passwort für E-Mail-Versand nicht verfügbar',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// SMTP-Einstellungen vom Provider
|
||
const settings = await getImapSmtpSettings();
|
||
|
||
if (!settings) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'Keine SMTP-Einstellungen konfiguriert',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// SMTP-Credentials
|
||
const credentials: SmtpCredentials = {
|
||
host: settings.smtpServer,
|
||
port: settings.smtpPort,
|
||
user: stressfreiEmail.email,
|
||
password,
|
||
encryption: settings.smtpEncryption,
|
||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||
};
|
||
|
||
// E-Mail-Parameter
|
||
const emailParams: SendEmailParams = {
|
||
to,
|
||
cc,
|
||
subject,
|
||
text,
|
||
html,
|
||
inReplyTo,
|
||
references,
|
||
attachments: attachments as EmailAttachment[] | undefined,
|
||
};
|
||
|
||
// E-Mail senden
|
||
const authReq = req as any;
|
||
const result = await sendEmail(credentials, stressfreiEmail.email, emailParams, {
|
||
context: 'customer-email',
|
||
customerId: stressfreiEmail.customerId,
|
||
triggeredBy: authReq.user?.email,
|
||
});
|
||
|
||
if (!result.success) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: result.error,
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Gesendete E-Mail im IMAP Sent-Ordner speichern (für Attachment-Download)
|
||
let sentUid: number | undefined;
|
||
if (result.rawEmail) {
|
||
try {
|
||
// IMAP-Credentials für Sent-Ordner
|
||
const imapCredentials: ImapCredentials = {
|
||
host: settings.imapServer,
|
||
port: settings.imapPort,
|
||
user: stressfreiEmail.email,
|
||
password,
|
||
encryption: settings.imapEncryption,
|
||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||
};
|
||
|
||
const appendResult = await appendToSent(imapCredentials, {
|
||
rawEmail: result.rawEmail,
|
||
});
|
||
|
||
if (appendResult.success && appendResult.uid) {
|
||
sentUid = appendResult.uid;
|
||
console.log(`[SMTP] Email stored in Sent folder with UID ${sentUid}`);
|
||
}
|
||
} catch (appendError) {
|
||
// Nicht kritisch - E-Mail wurde trotzdem gesendet
|
||
console.error('Error appending to IMAP Sent folder:', appendError);
|
||
}
|
||
}
|
||
|
||
// Gesendete E-Mail im Cache speichern
|
||
try {
|
||
// Anhangsnamen extrahieren falls vorhanden
|
||
const attachmentNames = attachments?.map((a: EmailAttachment) => a.filename) || [];
|
||
|
||
await cachedEmailService.createSentEmail(stressfreiEmailId, {
|
||
to,
|
||
cc,
|
||
subject,
|
||
text,
|
||
html,
|
||
messageId: result.messageId || `sent-${Date.now()}@opencrm.local`,
|
||
contractId: contractId ? parseInt(contractId) : undefined,
|
||
attachmentNames: attachmentNames.length > 0 ? attachmentNames : undefined,
|
||
uid: sentUid, // UID vom IMAP Sent-Ordner für Attachment-Download
|
||
});
|
||
} catch (saveError) {
|
||
// Fehler beim Speichern nicht kritisch - E-Mail wurde trotzdem gesendet
|
||
console.error('Error saving sent email to cache:', saveError);
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
data: { messageId: result.messageId },
|
||
} as ApiResponse);
|
||
} catch (error) {
|
||
console.error('sendEmailFromAccount error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Senden der E-Mail',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// ==================== ATTACHMENTS ====================
|
||
|
||
// Anhang-Liste einer E-Mail abrufen
|
||
export async function getAttachments(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const emailId = parseInt(req.params.emailId);
|
||
if (!(await canAccessCachedEmail(req, res, emailId))) 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;
|
||
}
|
||
|
||
// Anhänge aus attachmentNames parsen (JSON Array)
|
||
const attachmentNames: string[] = email.attachmentNames
|
||
? JSON.parse(email.attachmentNames)
|
||
: [];
|
||
|
||
res.json({
|
||
success: true,
|
||
data: attachmentNames.map((name) => ({ filename: name })),
|
||
} as ApiResponse);
|
||
} catch (error) {
|
||
console.error('getAttachments error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Laden der Anhänge',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// Einzelnen Anhang herunterladen
|
||
export async function downloadAttachment(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const emailId = parseInt(req.params.emailId);
|
||
const filename = decodeURIComponent(req.params.filename);
|
||
|
||
// Portal-Isolation: nur eigene/vertretene Emails
|
||
if (!(await canAccessCachedEmail(req, res, emailId))) 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;
|
||
}
|
||
|
||
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
|
||
// o.ä. sein. Für inline-Preview verlässt sich der Server nicht auf den
|
||
// gemeldeten Type, sondern prüft die Magic-Bytes des Buffer-Inhalts.
|
||
// Real-world-Problem (intern gemeldet 2026-05-30): manche Mail-Clients
|
||
// setzen für PDF-Anhänge `application/octet-stream` → unser alter
|
||
// Whitelist-Check fiel auf attachment zurück, der Browser öffnete
|
||
// trotz target="_blank" keinen neuen Tab. Mit Magic-Byte-Detection
|
||
// wird der echte Typ erkannt und inline-Preview klappt zuverlässig.
|
||
const buf: Buffer = attachment.content;
|
||
let detectedType: string | null = null;
|
||
if (buf.length >= 5 && buf.subarray(0, 5).toString('latin1') === '%PDF-') {
|
||
detectedType = 'application/pdf';
|
||
} else if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||
detectedType = 'image/png';
|
||
} else if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
||
detectedType = 'image/jpeg';
|
||
} else if (buf.length >= 6 && (buf.subarray(0, 6).toString('latin1') === 'GIF87a' || buf.subarray(0, 6).toString('latin1') === 'GIF89a')) {
|
||
detectedType = 'image/gif';
|
||
} else if (buf.length >= 12 && buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP') {
|
||
detectedType = 'image/webp';
|
||
} else if (buf.length >= 1 && (attachment.contentType || '').toLowerCase().startsWith('text/plain')) {
|
||
// text/plain hat keine eindeutige Magic-Byte – akzeptieren wenn
|
||
// der IMAP-Header das so meldet und Inhalt nur druckbare ASCII/UTF-8 ist.
|
||
// Konservative Prüfung: keine HTML-Tag-Anfänge.
|
||
const sample = buf.subarray(0, Math.min(buf.length, 256)).toString('utf8');
|
||
if (!/<[a-z!\/?]/i.test(sample)) {
|
||
detectedType = 'text/plain; charset=utf-8';
|
||
}
|
||
}
|
||
const isSafeInline = detectedType !== null;
|
||
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
||
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
|
||
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
|
||
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
|
||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||
// Bei sicherem Inline-Typ: erkannten Type setzen (überschreibt
|
||
// eventuell falsches application/octet-stream aus IMAP). Sonst
|
||
// octet-stream erzwingen, damit der Browser nichts erraten kann.
|
||
res.setHeader('Content-Type', isSafeInline ? detectedType! : 'application/octet-stream');
|
||
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
|
||
res.setHeader('Content-Length', attachment.size);
|
||
res.send(attachment.content);
|
||
} catch (error) {
|
||
console.error('downloadAttachment error:', error);
|
||
const rawMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||
const lower = rawMsg.toLowerCase();
|
||
|
||
let friendly = rawMsg;
|
||
if (lower.includes('socket disconnected') && lower.includes('tls')) {
|
||
friendly =
|
||
'IMAP-Server hat die TLS-Verbindung abgelehnt. Mögliche Ursache: selbstsigniertes Zertifikat. Bitte in den E-Mail-Provider-Einstellungen "Selbstsignierte Zertifikate erlauben" aktivieren.';
|
||
} else if (lower.includes('econnrefused')) {
|
||
friendly = 'IMAP-Server ist nicht erreichbar (Verbindung verweigert). Bitte Server/Port prüfen.';
|
||
} else if (lower.includes('etimedout')) {
|
||
friendly = 'Zeitüberschreitung beim Verbinden zum IMAP-Server. Bitte später erneut versuchen.';
|
||
} else if (lower.includes('authentication') || lower.includes('auth')) {
|
||
friendly = 'IMAP-Authentifizierung fehlgeschlagen. Bitte Zugangsdaten prüfen.';
|
||
}
|
||
|
||
res.status(500).json({
|
||
success: false,
|
||
error: `Fehler beim Herunterladen des Anhangs: ${friendly}`,
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// ==================== MAILBOX ACCOUNTS ====================
|
||
|
||
// Mailbox-Konten eines Kunden abrufen
|
||
export async function getMailboxAccounts(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const customerId = parseInt(req.params.customerId);
|
||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||
|
||
const accounts = await cachedEmailService.getMailboxAccountsForCustomer(customerId);
|
||
|
||
res.json({ success: true, data: accounts } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('getMailboxAccounts error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Laden der E-Mail-Konten',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// Mailbox nachträglich aktivieren
|
||
export async function enableMailbox(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const id = parseInt(req.params.id);
|
||
if (!(await canAccessStressfreiEmail(req, res, id))) return;
|
||
|
||
const result = await stressfreiEmailService.enableMailbox(id);
|
||
|
||
if (!result.success) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: result.error,
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
res.json({ success: true } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('enableMailbox error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Aktivieren der Mailbox',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// Mailbox-Status mit Provider synchronisieren
|
||
export async function syncMailboxStatus(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const id = parseInt(req.params.id);
|
||
if (!(await canAccessStressfreiEmail(req, res, id))) return;
|
||
|
||
const result = await stressfreiEmailService.syncMailboxStatus(id);
|
||
|
||
if (!result.success) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: result.error,
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
hasMailbox: result.hasMailbox,
|
||
wasUpdated: result.wasUpdated,
|
||
},
|
||
} as ApiResponse);
|
||
} catch (error) {
|
||
console.error('syncMailboxStatus error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Synchronisieren des Mailbox-Status',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// E-Mail-Thread abrufen
|
||
export async function getThread(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const id = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||
|
||
const thread = await cachedEmailService.getEmailThread(id);
|
||
|
||
res.json({ success: true, data: thread } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('getThread error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Laden des E-Mail-Threads',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// Mailbox-Zugangsdaten abrufen (IMAP/SMTP)
|
||
export async function getMailboxCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const id = parseInt(req.params.id);
|
||
// Ownership-Check: ohne diesen Check konnte ein Portal-Kunde mit
|
||
// bekannter Stressfrei-Email-ID die kompletten IMAP/SMTP-Credentials
|
||
// eines anderen Kunden abrufen (IDOR). Pentest-Finding 2026-05-XX.
|
||
if (!(await canAccessStressfreiEmail(req, res, id))) return;
|
||
|
||
// StressfreiEmail laden
|
||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(id);
|
||
|
||
if (!stressfreiEmail) {
|
||
res.status(404).json({
|
||
success: false,
|
||
error: 'E-Mail-Konto nicht gefunden',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
if (!stressfreiEmail.hasMailbox) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'Keine Mailbox für diese E-Mail-Adresse',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Passwort entschlüsseln
|
||
const password = await stressfreiEmailService.getDecryptedPassword(id);
|
||
|
||
if (!password) {
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Passwort konnte nicht entschlüsselt werden',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// IMAP/SMTP-Einstellungen laden
|
||
const settings = await getImapSmtpSettings();
|
||
|
||
// Klartext-Mailbox-Passwort-Read auditieren (CRITICAL)
|
||
await logChange({
|
||
req,
|
||
action: 'READ',
|
||
resourceType: 'MailboxCredentials',
|
||
resourceId: id.toString(),
|
||
label: `Klartext-Mailbox-Zugangsdaten von ${stressfreiEmail.email} entschlüsselt`,
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
email: stressfreiEmail.email,
|
||
password,
|
||
imap: settings ? {
|
||
server: settings.imapServer,
|
||
port: settings.imapPort,
|
||
encryption: settings.imapEncryption,
|
||
} : null,
|
||
smtp: settings ? {
|
||
server: settings.smtpServer,
|
||
port: settings.smtpPort,
|
||
encryption: settings.smtpEncryption,
|
||
} : null,
|
||
},
|
||
} as ApiResponse);
|
||
} catch (error) {
|
||
console.error('getMailboxCredentials error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Laden der Mailbox-Zugangsdaten',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// Ungelesene E-Mails zählen
|
||
export async function getUnreadCount(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const customerId = req.query.customerId ? parseInt(req.query.customerId as string) : undefined;
|
||
const contractId = req.query.contractId ? parseInt(req.query.contractId as string) : undefined;
|
||
|
||
let count = 0;
|
||
|
||
if (customerId) {
|
||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||
count = await cachedEmailService.getUnreadCountForCustomer(customerId);
|
||
} else if (contractId) {
|
||
if (!(await canAccessContract(req, res, contractId))) return;
|
||
count = await cachedEmailService.getUnreadCountForContract(contractId);
|
||
}
|
||
|
||
res.json({ success: true, data: { count } } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('getUnreadCount error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Zählen der ungelesenen E-Mails',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// E-Mail in Papierkorb verschieben (nur Admin)
|
||
export async function deleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const id = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||
|
||
// Prüfen ob E-Mail existiert
|
||
const email = await cachedEmailService.getCachedEmailById(id);
|
||
if (!email) {
|
||
res.status(404).json({
|
||
success: false,
|
||
error: 'E-Mail nicht gefunden',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
const result = await cachedEmailService.moveEmailToTrash(id);
|
||
|
||
if (!result.success) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: result.error,
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
res.json({ success: true, message: 'E-Mail in Papierkorb verschoben' } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('deleteEmail error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Löschen der E-Mail',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// ==================== TRASH OPERATIONS ====================
|
||
|
||
// Papierkorb-E-Mails für einen Kunden abrufen
|
||
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const customerId = parseInt(req.params.customerId);
|
||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||
|
||
const emails = await cachedEmailService.getTrashEmails(customerId);
|
||
|
||
res.json({ success: true, data: emails } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('getTrashEmails error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Laden der Papierkorb-E-Mails',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// Papierkorb-Anzahl für einen Kunden
|
||
export async function getTrashCount(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const customerId = parseInt(req.params.customerId);
|
||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||
|
||
const count = await cachedEmailService.getTrashCount(customerId);
|
||
|
||
res.json({ success: true, data: { count } } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('getTrashCount error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Zählen der Papierkorb-E-Mails',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// E-Mail aus Papierkorb wiederherstellen
|
||
export async function restoreEmail(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const id = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||
|
||
const result = await cachedEmailService.restoreEmailFromTrash(id);
|
||
|
||
if (!result.success) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: result.error,
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
res.json({ success: true, message: 'E-Mail wiederhergestellt' } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('restoreEmail error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim Wiederherstellen der E-Mail',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// E-Mail endgültig löschen (aus Papierkorb)
|
||
export async function permanentDeleteEmail(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const id = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, id))) return;
|
||
|
||
const result = await cachedEmailService.permanentDeleteEmail(id);
|
||
|
||
if (!result.success) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: result.error,
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
res.json({ success: true, message: 'E-Mail endgültig gelöscht' } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('permanentDeleteEmail error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Fehler beim endgültigen Löschen der E-Mail',
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// ==================== ATTACHMENT TARGETS ====================
|
||
|
||
// Verfügbare Dokumenten-Ziele für E-Mail-Anhänge abrufen
|
||
export async function getAttachmentTargets(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const emailId = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||
|
||
// 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;
|
||
type: string;
|
||
energyDetailsId?: number;
|
||
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,
|
||
type: true,
|
||
cancellationLetterPath: true,
|
||
cancellationConfirmationPath: true,
|
||
cancellationLetterOptionsPath: true,
|
||
cancellationConfirmationOptionsPath: true,
|
||
energyDetails: {
|
||
select: { id: true },
|
||
},
|
||
},
|
||
});
|
||
|
||
if (contract) {
|
||
const contractTargets = getContractTargets();
|
||
response.contract = {
|
||
id: contract.id,
|
||
contractNumber: contract.contractNumber,
|
||
type: contract.type,
|
||
energyDetailsId: contract.energyDetails?.id,
|
||
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: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const emailId = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ==================== SAVE EMAIL AS PDF ====================
|
||
|
||
// E-Mail als PDF exportieren und in Dokumentenfeld speichern
|
||
export async function saveEmailAsPdf(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const emailId = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||
const { entityType, entityId, targetKey } = req.body;
|
||
|
||
console.log('[saveEmailAsPdf] Request:', { emailId, 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 (mit Body)
|
||
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: true },
|
||
});
|
||
|
||
if (!stressfreiEmail) {
|
||
res.status(404).json({
|
||
success: false,
|
||
error: 'E-Mail-Konto nicht gefunden',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Empfänger-Adressen parsen (JSON Array)
|
||
let toAddresses: string[] = [];
|
||
let ccAddresses: string[] = [];
|
||
try {
|
||
toAddresses = JSON.parse(email.toAddresses);
|
||
} catch { toAddresses = [email.toAddresses]; }
|
||
try {
|
||
if (email.ccAddresses) ccAddresses = JSON.parse(email.ccAddresses);
|
||
} catch { /* ignore */ }
|
||
|
||
// PDF generieren
|
||
const pdfBuffer = await generateEmailPdf({
|
||
from: email.fromAddress,
|
||
to: toAddresses.join(', '),
|
||
cc: ccAddresses.length > 0 ? ccAddresses.join(', ') : undefined,
|
||
subject: email.subject || '(Kein Betreff)',
|
||
date: email.receivedAt,
|
||
bodyText: email.textBody || undefined,
|
||
bodyHtml: email.htmlBody || undefined,
|
||
});
|
||
|
||
// Ziel-Konfiguration finden
|
||
let targetConfig;
|
||
let targetDir: string;
|
||
let targetField: string;
|
||
|
||
console.log('[saveEmailAsPdf] 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('[saveEmailAsPdf] 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 uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||
const newFilename = `email-${uniqueSuffix}.pdf`;
|
||
const filePath = path.join(uploadsDir, newFilename);
|
||
const relativePath = `/uploads/${targetDir}/${newFilename}`;
|
||
|
||
// PDF speichern
|
||
fs.writeFileSync(filePath, pdfBuffer);
|
||
|
||
// Alte Datei löschen und DB aktualisieren
|
||
if (entityType === 'customer') {
|
||
const customer = stressfreiEmail.customer;
|
||
|
||
// 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 oder direkt
|
||
const contractId = email.contractId;
|
||
if (!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: 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: contractId },
|
||
data: { [targetField]: relativePath },
|
||
});
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
path: relativePath,
|
||
filename: newFilename,
|
||
size: pdfBuffer.length,
|
||
},
|
||
} as ApiResponse);
|
||
} catch (error) {
|
||
console.error('saveEmailAsPdf error:', error);
|
||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||
res.status(500).json({
|
||
success: false,
|
||
error: `Fehler beim Erstellen der PDF: ${errorMessage}`,
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// ==================== SAVE EMAIL AS INVOICE ====================
|
||
|
||
// E-Mail als PDF exportieren und als Rechnung speichern
|
||
export async function saveEmailAsInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const emailId = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||
const { invoiceDate, invoiceType, notes } = req.body;
|
||
|
||
console.log('[saveEmailAsInvoice] Request:', { emailId, invoiceDate, invoiceType, notes });
|
||
|
||
// Validierung
|
||
if (!invoiceDate || !invoiceType) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'invoiceDate und invoiceType sind erforderlich',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Validiere invoiceType
|
||
if (!['INTERIM', 'FINAL', 'NOT_AVAILABLE'].includes(invoiceType)) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'Ungültiger Rechnungstyp',
|
||
} 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;
|
||
}
|
||
|
||
// Prüfen ob E-Mail einem Vertrag zugeordnet ist
|
||
if (!email.contractId) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'E-Mail ist keinem Vertrag zugeordnet',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Vertrag laden
|
||
const contract = await prisma.contract.findUnique({
|
||
where: { id: email.contractId },
|
||
include: { energyDetails: true },
|
||
});
|
||
|
||
if (!contract) {
|
||
res.status(404).json({
|
||
success: false,
|
||
error: 'Vertrag nicht gefunden',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Empfänger-Adressen parsen (JSON Array)
|
||
let toAddresses: string[] = [];
|
||
let ccAddresses: string[] = [];
|
||
try {
|
||
toAddresses = JSON.parse(email.toAddresses);
|
||
} catch { toAddresses = [email.toAddresses]; }
|
||
try {
|
||
if (email.ccAddresses) ccAddresses = JSON.parse(email.ccAddresses);
|
||
} catch { /* ignore */ }
|
||
|
||
// PDF generieren
|
||
const pdfBuffer = await generateEmailPdf({
|
||
from: email.fromAddress,
|
||
to: toAddresses.join(', '),
|
||
cc: ccAddresses.length > 0 ? ccAddresses.join(', ') : undefined,
|
||
subject: email.subject || '(Kein Betreff)',
|
||
date: email.receivedAt,
|
||
bodyText: email.textBody || undefined,
|
||
bodyHtml: email.htmlBody || undefined,
|
||
});
|
||
|
||
// Uploads-Verzeichnis erstellen
|
||
const uploadsDir = path.join(process.cwd(), 'uploads', 'invoices');
|
||
if (!fs.existsSync(uploadsDir)) {
|
||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||
}
|
||
|
||
// Eindeutigen Dateinamen generieren
|
||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||
const newFilename = `invoice-email-${uniqueSuffix}.pdf`;
|
||
const filePath = path.join(uploadsDir, newFilename);
|
||
const relativePath = `/uploads/invoices/${newFilename}`;
|
||
|
||
// PDF speichern
|
||
fs.writeFileSync(filePath, pdfBuffer);
|
||
|
||
// Invoice in DB erstellen (für alle Vertragstypen)
|
||
const invoice = contract.energyDetails
|
||
? await invoiceService.addInvoice(contract.energyDetails.id, {
|
||
invoiceDate: new Date(invoiceDate),
|
||
invoiceType,
|
||
documentPath: relativePath,
|
||
notes: notes || undefined,
|
||
})
|
||
: await invoiceService.addInvoiceByContract(contract.id, {
|
||
invoiceDate: new Date(invoiceDate),
|
||
invoiceType,
|
||
documentPath: relativePath,
|
||
notes: notes || undefined,
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
data: invoice,
|
||
} as ApiResponse);
|
||
} catch (error) {
|
||
console.error('saveEmailAsInvoice error:', error);
|
||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||
res.status(500).json({
|
||
success: false,
|
||
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// ==================== SAVE EMAIL AS CONTRACT DOCUMENT ====================
|
||
|
||
// E-Mail als PDF exportieren und als Vertragsdokument hinterlegen.
|
||
// Parallel zu saveAttachmentAsContractDocument (Anhang-Variante) – damit
|
||
// auch reine Mail-Bestätigungen ohne Anhang als Auftragsformular/
|
||
// Lieferbestätigung etc. an einem Vertrag landen können.
|
||
export async function saveEmailAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const emailId = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||
const { documentType, notes } = req.body;
|
||
|
||
// Pentest 58.1: Whitelist-Validierung des documentType.
|
||
let validatedType: string;
|
||
try {
|
||
validatedType = validateContractDocumentType(documentType);
|
||
} catch (err) {
|
||
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiger documentType' } as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||
if (!email) {
|
||
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
||
return;
|
||
}
|
||
if (!email.contractId) {
|
||
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 },
|
||
select: { id: true, contractNumber: true, customerId: true },
|
||
});
|
||
if (!contract) {
|
||
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' } as ApiResponse);
|
||
return;
|
||
}
|
||
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
|
||
|
||
// Empfänger-Adressen parsen
|
||
let toAddresses: string[] = [];
|
||
let ccAddresses: string[] = [];
|
||
try { toAddresses = JSON.parse(email.toAddresses); } catch { toAddresses = [email.toAddresses]; }
|
||
try { if (email.ccAddresses) ccAddresses = JSON.parse(email.ccAddresses); } catch { /* ignore */ }
|
||
|
||
const pdfBuffer = await generateEmailPdf({
|
||
from: email.fromAddress,
|
||
to: toAddresses.join(', '),
|
||
cc: ccAddresses.length > 0 ? ccAddresses.join(', ') : undefined,
|
||
subject: email.subject || '(Kein Betreff)',
|
||
date: email.receivedAt,
|
||
bodyText: email.textBody || undefined,
|
||
bodyHtml: email.htmlBody || undefined,
|
||
});
|
||
|
||
const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents');
|
||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||
|
||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||
const safeType = validatedType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||
const newFilename = `${safeType}-email-${uniqueSuffix}.pdf`;
|
||
const filePath = path.join(uploadsDir, newFilename);
|
||
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||
|
||
// Pentest 55.4: Lock vor Schreiben, damit parallele Requests blockieren.
|
||
// PDF erst INNERHALB des Locks auf Disk schreiben → kein verwaister
|
||
// Datei-Müll bei Race-Reject.
|
||
const doc = await withContractDocumentLock(contract.id, validatedType, async () => {
|
||
fs.writeFileSync(filePath, pdfBuffer);
|
||
return prisma.contractDocument.create({
|
||
data: {
|
||
contractId: contract.id,
|
||
documentType: validatedType,
|
||
documentPath: relativePath,
|
||
originalName: `${email.subject || 'email'}.pdf`,
|
||
notes: sanitizeNotes(notes),
|
||
uploadedBy: (req as any).user?.email || 'email-import',
|
||
},
|
||
});
|
||
});
|
||
|
||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
||
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
||
|
||
res.json({ success: true, data: doc } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('saveEmailAsContractDocument error:', error);
|
||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||
res.status(500).json({ success: false, error: `Fehler beim Speichern: ${errorMessage}` } as ApiResponse);
|
||
}
|
||
}
|
||
|
||
// ==================== SAVE ATTACHMENT AS INVOICE ====================
|
||
|
||
// E-Mail-Anhang als Rechnung speichern
|
||
export async function saveAttachmentAsInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const emailId = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||
const filename = decodeURIComponent(req.params.filename);
|
||
const { invoiceDate, invoiceType, notes } = req.body;
|
||
|
||
console.log('[saveAttachmentAsInvoice] Request:', { emailId, filename, invoiceDate, invoiceType, notes });
|
||
|
||
// Validierung
|
||
if (!invoiceDate || !invoiceType) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'invoiceDate und invoiceType sind erforderlich',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Validiere invoiceType
|
||
if (!['INTERIM', 'FINAL', 'NOT_AVAILABLE'].includes(invoiceType)) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'Ungültiger Rechnungstyp',
|
||
} 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;
|
||
}
|
||
|
||
// Prüfen ob E-Mail einem Vertrag zugeordnet ist
|
||
if (!email.contractId) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'E-Mail ist keinem Vertrag zugeordnet',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Vertrag laden
|
||
const contract = await prisma.contract.findUnique({
|
||
where: { id: email.contractId },
|
||
include: { energyDetails: true },
|
||
});
|
||
|
||
if (!contract) {
|
||
res.status(404).json({
|
||
success: false,
|
||
error: 'Vertrag nicht gefunden',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Für gesendete E-Mails: Prüfen ob UID vorhanden
|
||
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 zusammenstellen
|
||
const credentials: ImapCredentials = {
|
||
host: settings.imapServer,
|
||
port: settings.imapPort,
|
||
user: stressfreiEmail.email,
|
||
password,
|
||
encryption: settings.imapEncryption,
|
||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||
};
|
||
|
||
// IMAP-Ordner bestimmen
|
||
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||
|
||
// Anhang vom IMAP-Server laden
|
||
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
|
||
if (!attachment) {
|
||
res.status(404).json({
|
||
success: false,
|
||
error: 'Anhang nicht gefunden oder nicht mehr verfügbar',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Uploads-Verzeichnis erstellen
|
||
const uploadsDir = path.join(process.cwd(), 'uploads', 'invoices');
|
||
if (!fs.existsSync(uploadsDir)) {
|
||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||
}
|
||
|
||
// Dateiendung extrahieren
|
||
const ext = path.extname(filename) || '.pdf';
|
||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||
const newFilename = `invoice-attachment-${uniqueSuffix}${ext}`;
|
||
const filePath = path.join(uploadsDir, newFilename);
|
||
const relativePath = `/uploads/invoices/${newFilename}`;
|
||
|
||
// Datei speichern
|
||
fs.writeFileSync(filePath, attachment.content);
|
||
|
||
// Invoice in DB erstellen (für alle Vertragstypen)
|
||
const invoice = contract.energyDetails
|
||
? await invoiceService.addInvoice(contract.energyDetails.id, {
|
||
invoiceDate: new Date(invoiceDate),
|
||
invoiceType,
|
||
documentPath: relativePath,
|
||
notes: notes || undefined,
|
||
})
|
||
: await invoiceService.addInvoiceByContract(contract.id, {
|
||
invoiceDate: new Date(invoiceDate),
|
||
invoiceType,
|
||
documentPath: relativePath,
|
||
notes: notes || undefined,
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
data: invoice,
|
||
} as ApiResponse);
|
||
} catch (error) {
|
||
console.error('saveAttachmentAsInvoice error:', error);
|
||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||
res.status(500).json({
|
||
success: false,
|
||
error: `Fehler beim Erstellen der Rechnung: ${errorMessage}`,
|
||
} as ApiResponse);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Anhang einer E-Mail als Vertragsdokument (ContractDocument) speichern.
|
||
* Nutzt die flexible ContractDocument-Tabelle mit documentType (Auftragsformular, Lieferbestätigung, etc.)
|
||
*/
|
||
export async function saveAttachmentAsContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
||
try {
|
||
const emailId = parseInt(req.params.id);
|
||
if (!(await canAccessCachedEmail(req, res, emailId))) return;
|
||
const filename = decodeURIComponent(req.params.filename);
|
||
const { documentType, notes } = req.body;
|
||
|
||
// Pentest 58.1: Whitelist-Validierung des documentType.
|
||
let validatedType: string;
|
||
try {
|
||
validatedType = validateContractDocumentType(documentType);
|
||
} catch (err) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: err instanceof Error ? err.message : 'Ungültiger documentType',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
const email = await cachedEmailService.getCachedEmailById(emailId);
|
||
if (!email) {
|
||
res.status(404).json({ success: false, error: 'E-Mail nicht gefunden' } as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
if (!email.contractId) {
|
||
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 },
|
||
select: { id: true, contractNumber: true, customerId: true },
|
||
});
|
||
|
||
if (!contract) {
|
||
res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' } as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Ownership-Check (Portal-Kunde darf nur auf eigenen/vertretenen Vertrag)
|
||
if (!(await canAccessContract(req as AuthRequest, res, contract.id))) return;
|
||
|
||
// Für gesendete E-Mails: Prüfen ob UID vorhanden
|
||
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 für IMAP-Zugangsdaten
|
||
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;
|
||
}
|
||
|
||
const settings = await getImapSmtpSettings();
|
||
if (!settings) {
|
||
res.status(400).json({
|
||
success: false,
|
||
error: 'Keine E-Mail-Provider-Einstellungen gefunden',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
const password = decrypt(stressfreiEmail.emailPasswordEncrypted);
|
||
|
||
const credentials: ImapCredentials = {
|
||
host: settings.imapServer,
|
||
port: settings.imapPort,
|
||
user: stressfreiEmail.email,
|
||
password,
|
||
encryption: settings.imapEncryption,
|
||
allowSelfSignedCerts: settings.allowSelfSignedCerts,
|
||
};
|
||
|
||
const imapFolder = email.folder === 'SENT' ? 'Sent' : 'INBOX';
|
||
|
||
const attachment = await fetchAttachment(credentials, email.uid, filename, imapFolder);
|
||
if (!attachment) {
|
||
res.status(404).json({
|
||
success: false,
|
||
error: 'Anhang nicht gefunden oder nicht mehr verfügbar',
|
||
} as ApiResponse);
|
||
return;
|
||
}
|
||
|
||
// Uploads-Verzeichnis
|
||
const uploadsDir = path.join(process.cwd(), 'uploads', 'contract-documents');
|
||
if (!fs.existsSync(uploadsDir)) {
|
||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||
}
|
||
|
||
const ext = path.extname(filename) || '.pdf';
|
||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||
const safeType = validatedType.toLowerCase().replace(/[^a-z0-9]/g, '-');
|
||
const newFilename = `${safeType}-${uniqueSuffix}${ext}`;
|
||
const filePath = path.join(uploadsDir, newFilename);
|
||
const relativePath = `/uploads/contract-documents/${newFilename}`;
|
||
|
||
// Pentest 55.4: Lock vor Schreiben (siehe saveEmailAsContractDocument).
|
||
const doc = await withContractDocumentLock(contract.id, validatedType, async () => {
|
||
fs.writeFileSync(filePath, attachment.content);
|
||
return prisma.contractDocument.create({
|
||
data: {
|
||
contractId: contract.id,
|
||
documentType: validatedType,
|
||
documentPath: relativePath,
|
||
originalName: filename,
|
||
notes: sanitizeNotes(notes),
|
||
uploadedBy: (req as any).user?.email || 'email-import',
|
||
},
|
||
});
|
||
});
|
||
|
||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
||
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
|
||
|
||
res.json({ success: true, data: doc } as ApiResponse);
|
||
} catch (error) {
|
||
console.error('saveAttachmentAsContractDocument error:', error);
|
||
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||
res.status(500).json({
|
||
success: false,
|
||
error: `Fehler beim Speichern: ${errorMessage}`,
|
||
} as ApiResponse);
|
||
}
|
||
}
|