Files
opencrm/backend/src/controllers/cachedEmail.controller.ts
T
duffyduckandClaude Opus 4.7 627cae9b3b Spam-Anhänge: klarer Fehler statt Junk-Ordner-Raterei (Pentest R124)
Nachtrag zum R124-Fix: statt bei nicht auffindbarem Junk-Ordner still
auf INBOX bzw. den geratenen String 'Junk' zurückzufallen, wird jetzt
ein klarer Fehler zurückgegeben – sonst könnte im Randfall (Junk-Ordner
serverseitig entfernt/umbenannt) doch wieder die falsche UID aus dem
falschen Ordner gezogen werden.

- 4 Controller-Funktionen (downloadAttachment, saveAttachmentTo,
  saveAttachmentAsInvoice, saveAttachmentAsContractDocument):
  404 "Postfach hat keinen Spam-/Junk-Ordner (mehr)".
- 2 Service-Funktionen (moveEmailToTrash, restoreEmailFromTrash):
  klare error-Message im TrashOperationResult.

Kein undefined mehr Richtung IMAP-Lib.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-27 16:30:49 +02:00

2523 lines
86 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==================== 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, humanizeImapError, findJunkFolderPath } from '../services/imapService.js';
import { getImapSmtpSettings } from '../services/emailProvider/emailProviderService.js';
import { decrypt } from '../utils/encryption.js';
import { sanitizeNotes, stripHtml, validateContractDocumentType, validateOptionalIsoDate, assertSafePdf } from '../utils/sanitize.js';
import { ApiError } from '../utils/apiError.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;
}
// Pentest 91.1 (LOW, 2026-06-21): `accountId=abc` → `parseInt` = `NaN`
// → der Ternary gab `NaN` an den Service. `if (NaN)` ist falsy → der
// Postfach-Filter fiel weg, und der Vertrag zeigte Mails aus ALLEN
// Postfächern.
//
// Pentest 92 (LOW, 2026-06-21): Bei `accountId=abc` auf Vertrags-
// Endpunkten reichte das silent-undefined nicht die Mailbox-Isolation
// brach (man sah Mails aus allen Postfächern statt 0). Strict-400, weil
// Verträge per Design IMMER ein bestimmtes Postfach meinen.
//
// Helper hat zwei Modi:
// - default (optional): fehlend/leer → undefined (kein Filter)
// invalid → 400
// - { required: true }: fehlend/leer → 400
// invalid → 400
// Bei 400 schreibt der Helper direkt die Response und gibt `null`
// zurück; der Caller bricht dann mit `return` ab.
function parsePositiveIntQuery(
v: unknown,
fieldLabel: string,
res: Response,
options?: { required?: boolean },
): number | undefined | null {
// Pentest 93.1 (INFO, 2026-06-21): `?accountId=` (explizit-leer) wurde
// wie `?accountId` weggelassen behandelt → 200 statt 400 auf optionalen
// Endpunkten. Spec sagt aber: leerer String ist KEINE gültige Zahl.
// Trennung jetzt strikt:
// - Param fehlt komplett (`undefined`) → "absent"
// - Param da, aber Wert leer/Whitespace/keine Zahl → invalid → 400
if (v === undefined) {
if (options?.required) {
res.status(400).json({
success: false,
error: `${fieldLabel} ist erforderlich (positive Ganzzahl).`,
} as ApiResponse);
return null;
}
return undefined;
}
if (typeof v !== 'string') {
res.status(400).json({
success: false,
error: `${fieldLabel} muss als Zahl übergeben werden.`,
} as ApiResponse);
return null;
}
const trimmed = v.trim();
if (trimmed === '') {
res.status(400).json({
success: false,
error: `${fieldLabel} darf nicht leer sein bitte weglassen oder positive Ganzzahl angeben.`,
} as ApiResponse);
return null;
}
const n = parseInt(trimmed, 10);
if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
res.status(400).json({
success: false,
error: `${fieldLabel} muss eine positive Ganzzahl sein.`,
} as ApiResponse);
return null;
}
return n;
}
// 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;
// Customer-Inbox: accountId ist legitim optional (cross-mailbox-Ansicht
// ist erwünscht), aber invalide Werte → 400.
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
if (stressfreiEmailId === null) 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({
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.
// `accountId` (optional) schränkt zusätzlich auf ein bestimmtes Postfach
// ein ohne, sieht man im Vertrags-Tab Mails aus ALLEN Postfächern des
// Kunden, die dem Vertrag zugeordnet sind (User-Bug 2026-06-21).
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
// Vertrags-Endpunkte sind per Design IMMER pro Postfach fehlt
// accountId, ist die Abfrage semantisch ungültig. Strict-400.
// Frontend hat eh ein `enabled: !!selectedAccountId`-Guard.
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res, { required: true });
if (stressfreiEmailId === null || stressfreiEmailId === undefined) return;
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,
stressfreiEmailId,
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 (optional pro Postfach)
export async function getContractFolderCounts(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.contractId);
if (!(await canAccessContract(req, res, contractId))) return;
// Wie getEmailsForContract: Postfach ist required (sonst zeigt der
// Badge eine andere Zahl als die Liste).
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res, { required: true });
if (stressfreiEmailId === null || stressfreiEmailId === undefined) return;
const counts = await cachedEmailService.getFolderCountsForContract(contractId, stressfreiEmailId);
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;
}
// Pentest 97.1 + 97.2 (LOW/INFO, 2026-06-21): Attachment-Validierung
// vor `Buffer.from(...)` damit malformed Content (null, true, leerer
// String, falsches Base64) nicht erst tief im SMTP-Service kracht und
// dort die rohe Node.js-Fehlermeldung in der Response landet. Außerdem
// explizite App-Level-Caps (10 MB pro Datei, 25 MB total, 25 Anhänge),
// damit die Frontend-Doku auch ohne bodyParser-Heroics gilt.
const MAX_ATTACHMENT_COUNT = 25;
const MAX_PER_FILE_BYTES = 10 * 1024 * 1024;
const MAX_TOTAL_BYTES = 25 * 1024 * 1024;
// Standard-Base64-Alphabet inkl. optionalem `=`-Padding (kein URL-safe).
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
interface AttachmentValidationError {
status: 400;
error: string;
}
function validateAttachments(
attachments: unknown,
): { ok: true } | AttachmentValidationError {
if (attachments === undefined) return { ok: true };
if (!Array.isArray(attachments)) {
return { status: 400, error: 'attachments muss ein Array sein.' };
}
if (attachments.length > MAX_ATTACHMENT_COUNT) {
return {
status: 400,
error: `Maximal ${MAX_ATTACHMENT_COUNT} Anhänge pro E-Mail erlaubt (${attachments.length} übermittelt).`,
};
}
let totalBytes = 0;
for (let i = 0; i < attachments.length; i++) {
const a = attachments[i];
const label = `Anhang ${i + 1}`;
if (!a || typeof a !== 'object') {
return { status: 400, error: `${label} hat das falsche Format.` };
}
const filename = (a as Record<string, unknown>).filename;
const content = (a as Record<string, unknown>).content;
const contentType = (a as Record<string, unknown>).contentType;
if (typeof filename !== 'string' || filename.trim() === '') {
return { status: 400, error: `${label} hat keinen Dateinamen.` };
}
if (typeof content !== 'string' || content.length === 0) {
return {
status: 400,
error: `Anhang "${filename}" hat keinen Inhalt (content muss ein nicht-leerer Base64-String sein).`,
};
}
if (!BASE64_RE.test(content)) {
return {
status: 400,
error: `Anhang "${filename}" ist kein gültiger Base64-String.`,
};
}
if (contentType !== undefined && typeof contentType !== 'string') {
return {
status: 400,
error: `Anhang "${filename}" hat ein ungültiges contentType-Feld.`,
};
}
// Größen-Abschätzung anhand der Base64-Länge: jedes 4-Zeichen-Quartett
// dekodiert zu max 3 Bytes. So vermeiden wir Buffer.from() schon hier.
const approxBytes = Math.ceil(content.length * 0.75);
if (approxBytes > MAX_PER_FILE_BYTES) {
return {
status: 400,
error: `Anhang "${filename}" überschreitet die Maximalgröße von ${MAX_PER_FILE_BYTES / 1024 / 1024} MB.`,
};
}
totalBytes += approxBytes;
if (totalBytes > MAX_TOTAL_BYTES) {
return {
status: 400,
error: `Gesamtgröße aller Anhänge überschreitet ${MAX_TOTAL_BYTES / 1024 / 1024} MB.`,
};
}
}
return { ok: true };
}
// 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;
}
// Pentest 97.1 + 97.2: Attachment-Validierung vor Buffer.from()
// (Format, Größe, Anzahl) sonst leakte der rohe Node.js-Fehler
// in die Response und Limits waren nur Frontend-Doku.
const attachmentCheck = validateAttachments(attachments);
if (!('ok' in attachmentCheck)) {
res.status(attachmentCheck.status).json({
success: false,
error: attachmentCheck.error,
} 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)
// SPAM-Mails liegen im Junk-Ordner (Name variiert je Server) IMAP-UIDs
// sind pro Ordner vergeben, daher MUSS der echte Junk-Pfad ermittelt
// werden, sonst wird der Anhang im falschen Ordner gesucht (404 oder,
// bei UID-Kollision, der falsche Anhang aus INBOX). Gleiches Muster wie
// cachedEmail.service.ts::moveEmailToTrash.
let imapFolder: string;
if (email.folder === 'SENT') {
imapFolder = 'Sent';
} else if (email.folder === 'SPAM') {
// Kein stiller Fallback: findet sich der Junk-Ordner nicht (mehr),
// lieber ein klarer Fehler als aus dem falschen Ordner (INBOX/'Junk')
// die falsche UID zu ziehen.
const junkPath = await findJunkFolderPath(credentials);
if (!junkPath) {
res.status(404).json({
success: false,
error: 'Postfach hat keinen Spam-/Junk-Ordner (mehr) Anhang nicht abrufbar',
} as ApiResponse);
return;
}
imapFolder = junkPath;
} else {
imapFolder = '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);
res.status(500).json({
success: false,
error: `Fehler beim Herunterladen des Anhangs: ${humanizeImapError(error)}`,
} 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.
// Optional `accountId` (Postfach-Filter) und `contractId` (Vertrags-Filter)
// beide aus User-Bug 2026-06-21. Wenn beide leer sind, Verhalten wie
// vorher: alle gelöschten E-Mails des Kunden.
export async function getTrashEmails(req: AuthRequest, res: Response): Promise<void> {
try {
const customerId = parseInt(req.params.customerId);
if (!(await canAccessCustomer(req, res, customerId))) return;
// Trash auf Kundenebene: Filter sind optional (Cross-Mailbox-Trash-
// View ist legitim), invalide Werte → 400.
const stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
if (stressfreiEmailId === null) return;
const contractId = parsePositiveIntQuery(req.query.contractId, 'contractId', res);
if (contractId === null) return;
const emails = await cachedEmailService.getTrashEmails(customerId, {
stressfreiEmailId,
contractId,
});
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 (gleiche Filter wie getTrashEmails)
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 stressfreiEmailId = parsePositiveIntQuery(req.query.accountId, 'accountId', res);
if (stressfreiEmailId === null) return;
const contractId = parsePositiveIntQuery(req.query.contractId, 'contractId', res);
if (contractId === null) return;
const count = await cachedEmailService.getTrashCount(customerId, {
stressfreiEmailId,
contractId,
});
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)
// SPAM-Mails liegen im Junk-Ordner (Name variiert je Server) IMAP-UIDs
// sind pro Ordner vergeben, daher MUSS der echte Junk-Pfad ermittelt
// werden, sonst wird der Anhang im falschen Ordner gesucht (404 oder,
// bei UID-Kollision, der falsche Anhang aus INBOX). Gleiches Muster wie
// cachedEmail.service.ts::moveEmailToTrash.
let imapFolder: string;
if (email.folder === 'SENT') {
imapFolder = 'Sent';
} else if (email.folder === 'SPAM') {
// Kein stiller Fallback: findet sich der Junk-Ordner nicht (mehr),
// lieber ein klarer Fehler als aus dem falschen Ordner (INBOX/'Junk')
// die falsche UID zu ziehen.
const junkPath = await findJunkFolderPath(credentials);
if (!junkPath) {
res.status(404).json({
success: false,
error: 'Postfach hat keinen Spam-/Junk-Ordner (mehr) Anhang nicht abrufbar',
} as ApiResponse);
return;
}
imapFolder = junkPath;
} else {
imapFolder = '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}`;
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen (JS/Launch/Embed).
assertSafePdf(attachment.content);
// 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 status = error instanceof ApiError ? error.statusCode : 500;
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(status).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;
}
// Pentest 62.7: deliveryDate früh validieren, bevor wir Dateien schreiben.
let deliveryDate: string | null;
try {
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
} catch (err) {
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } 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.
// deliveryDate wurde oben schon validiert (Pentest 62.7).
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
res.json({ success: true, data: doc } as ApiResponse);
} catch (error) {
console.error('saveEmailAsContractDocument error:', error);
// Pentest 64.1: ApiError mit eigenem statusCode (z.B. 400 vom Race-
// Lock) statt pauschal 500.
const status = error instanceof ApiError ? error.statusCode : 500;
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(status).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
// SPAM-Mails liegen im Junk-Ordner (Name variiert je Server) IMAP-UIDs
// sind pro Ordner vergeben, daher MUSS der echte Junk-Pfad ermittelt
// werden, sonst wird der Anhang im falschen Ordner gesucht (404 oder,
// bei UID-Kollision, der falsche Anhang aus INBOX). Gleiches Muster wie
// cachedEmail.service.ts::moveEmailToTrash.
let imapFolder: string;
if (email.folder === 'SENT') {
imapFolder = 'Sent';
} else if (email.folder === 'SPAM') {
// Kein stiller Fallback: findet sich der Junk-Ordner nicht (mehr),
// lieber ein klarer Fehler als aus dem falschen Ordner (INBOX/'Junk')
// die falsche UID zu ziehen.
const junkPath = await findJunkFolderPath(credentials);
if (!junkPath) {
res.status(404).json({
success: false,
error: 'Postfach hat keinen Spam-/Junk-Ordner (mehr) Anhang nicht abrufbar',
} as ApiResponse);
return;
}
imapFolder = junkPath;
} else {
imapFolder = '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}`;
// Pentest 68.1: PDF-Anhänge auf aktive Inhalte scannen.
assertSafePdf(attachment.content);
// 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 status = error instanceof ApiError ? error.statusCode : 500;
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(status).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;
}
// Pentest 62.7: deliveryDate früh validieren, bevor wir Dateien schreiben.
let deliveryDate: string | null;
try {
deliveryDate = validateOptionalIsoDate(req.body?.deliveryDate, 'deliveryDate');
} catch (err) {
res.status(400).json({ success: false, error: err instanceof Error ? err.message : 'Ungültiges Lieferdatum' } 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,
};
// SPAM-Mails liegen im Junk-Ordner (Name variiert je Server) IMAP-UIDs
// sind pro Ordner vergeben, daher MUSS der echte Junk-Pfad ermittelt
// werden, sonst wird der Anhang im falschen Ordner gesucht (404 oder,
// bei UID-Kollision, der falsche Anhang aus INBOX). Gleiches Muster wie
// cachedEmail.service.ts::moveEmailToTrash.
let imapFolder: string;
if (email.folder === 'SENT') {
imapFolder = 'Sent';
} else if (email.folder === 'SPAM') {
// Kein stiller Fallback: findet sich der Junk-Ordner nicht (mehr),
// lieber ein klarer Fehler als aus dem falschen Ordner (INBOX/'Junk')
// die falsche UID zu ziehen.
const junkPath = await findJunkFolderPath(credentials);
if (!junkPath) {
res.status(404).json({
success: false,
error: 'Postfach hat keinen Spam-/Junk-Ordner (mehr) Anhang nicht abrufbar',
} as ApiResponse);
return;
}
imapFolder = junkPath;
} else {
imapFolder = '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 68.1: PDF-Anhänge auf aktive Inhalte scannen.
assertSafePdf(attachment.content);
// 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.
// deliveryDate wurde oben schon validiert (Pentest 62.7).
await maybeActivateOnDeliveryConfirmation(contract.id, validatedType, req, deliveryDate);
res.json({ success: true, data: doc } as ApiResponse);
} catch (error) {
console.error('saveAttachmentAsContractDocument error:', error);
// Pentest 64.1: ApiError mit eigenem statusCode statt pauschal 500.
const status = error instanceof ApiError ? error.statusCode : 500;
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
res.status(status).json({
success: false,
error: `Fehler beim Speichern: ${errorMessage}`,
} as ApiResponse);
}
}