save email as pdf likae attachment version 2

This commit is contained in:
2026-02-04 19:49:09 +01:00
parent d98c97a81f
commit 2d052c76d9
21 changed files with 1143 additions and 151 deletions
@@ -9,6 +9,7 @@ import { getImapSmtpSettings } from '../services/emailProvider/emailProviderServ
import { decrypt } from '../utils/encryption.js';
import { ApiResponse } from '../types/index.js';
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
import { generateEmailPdf } from '../services/pdfService.js';
import { PrismaClient, DocumentType } from '@prisma/client';
import path from 'path';
import fs from 'fs';
@@ -1270,3 +1271,250 @@ export async function saveAttachmentTo(req: Request, res: Response): Promise<voi
} as ApiResponse);
}
}
// ==================== SAVE EMAIL AS PDF ====================
// E-Mail als PDF exportieren und in Dokumentenfeld speichern
export async function saveEmailAsPdf(req: Request, res: Response): Promise<void> {
try {
const emailId = parseInt(req.params.id);
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);
}
}
+9
View File
@@ -176,6 +176,15 @@ router.post(
cachedEmailController.saveAttachmentTo
);
// E-Mail als PDF exportieren und speichern
// POST /api/emails/:id/save-as-pdf { entityType, entityId?, targetKey }
router.post(
'/emails/:id/save-as-pdf',
authenticate,
requirePermission('customers:update'),
cachedEmailController.saveEmailAsPdf
);
// ==================== VERTRAGSZUORDNUNG ====================
// E-Mail Vertrag zuordnen
+161 -140
View File
@@ -1,153 +1,174 @@
import puppeteer from 'puppeteer';
// ==================== PDF SERVICE ====================
import PDFDocument from 'pdfkit';
interface EmailData {
from: string;
to: string;
cc?: string;
subject: string;
date: Date;
bodyText?: string;
bodyHtml?: string;
}
/**
* Konvertiert HTML zu PDF mit Puppeteer
* Generiert ein PDF aus einer E-Mail
*/
export async function htmlToPdf(html: string): Promise<Buffer> {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
export async function generateEmailPdf(email: EmailData): Promise<Buffer> {
return new Promise((resolve, reject) => {
try {
const doc = new PDFDocument({
size: 'A4',
margins: { top: 50, bottom: 50, left: 50, right: 50 },
});
const chunks: Buffer[] = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
// Header
doc
.fontSize(18)
.font('Helvetica-Bold')
.text('E-Mail', { align: 'center' });
doc.moveDown(1.5);
// Metadaten-Tabelle
doc.fontSize(10).font('Helvetica');
// Von
doc
.font('Helvetica-Bold')
.text('Von: ', { continued: true })
.font('Helvetica')
.text(email.from);
// An
doc
.font('Helvetica-Bold')
.text('An: ', { continued: true })
.font('Helvetica')
.text(email.to);
// CC (falls vorhanden)
if (email.cc) {
doc
.font('Helvetica-Bold')
.text('CC: ', { continued: true })
.font('Helvetica')
.text(email.cc);
}
// Datum
const formattedDate = email.date.toLocaleString('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
});
doc
.font('Helvetica-Bold')
.text('Datum: ', { continued: true })
.font('Helvetica')
.text(formattedDate);
// Betreff
doc
.font('Helvetica-Bold')
.text('Betreff: ', { continued: true })
.font('Helvetica')
.text(email.subject || '(Kein Betreff)');
doc.moveDown(1);
// Trennlinie
doc
.moveTo(50, doc.y)
.lineTo(doc.page.width - 50, doc.y)
.stroke();
doc.moveDown(1);
// Inhalt
doc.fontSize(11);
// HTML in Text konvertieren (vereinfacht)
let content = email.bodyText || '';
if (!content && email.bodyHtml) {
// Einfache HTML-zu-Text Konvertierung
content = htmlToPlainText(email.bodyHtml);
}
if (!content) {
content = '(Kein Inhalt)';
}
// Text mit Zeilenumbrüchen ausgeben
doc.text(content, {
align: 'left',
lineGap: 2,
});
// Footer mit Erstellungsdatum
const footerY = doc.page.height - 40;
doc
.fontSize(8)
.fillColor('#666666')
.text(
`Exportiert am ${new Date().toLocaleString('de-DE')}`,
50,
footerY,
{ align: 'center', width: doc.page.width - 100 }
);
doc.end();
} catch (error) {
reject(error);
}
});
try {
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({
format: 'A4',
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm',
},
printBackground: true,
});
return Buffer.from(pdfBuffer);
} finally {
await browser.close();
}
}
/**
* Baut ein HTML-Dokument für eine E-Mail mit Header
* Konvertiert HTML in Plain-Text (vereinfacht)
*/
export function buildEmailHtml(email: {
subject?: string | null;
fromAddress: string;
fromName?: string | null;
toAddresses: string;
receivedAt: Date;
htmlBody?: string | null;
textBody?: string | null;
}): string {
const formatDate = (date: Date) => {
return new Date(date).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
function htmlToPlainText(html: string): string {
let text = html;
// To-Adressen parsen (JSON Array)
let toList: string[] = [];
try {
toList = JSON.parse(email.toAddresses);
} catch {
toList = [email.toAddresses];
}
// Zeilenumbrüche vor Block-Elementen
text = text.replace(/<(br|p|div|h[1-6]|li|tr)[^>]*>/gi, '\n');
const fromDisplay = email.fromName
? `${email.fromName} <${email.fromAddress}>`
: email.fromAddress;
// Listen-Elemente
text = text.replace(/<li[^>]*>/gi, '\n• ');
const body = email.htmlBody || `<pre style="white-space: pre-wrap; font-family: inherit;">${email.textBody || ''}</pre>`;
// Links mit URL
text = text.replace(/<a[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, '$2 ($1)');
return `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
* {
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
font-size: 12pt;
line-height: 1.5;
color: #333;
margin: 0;
padding: 0;
}
.email-header {
background: #f5f5f5;
border-bottom: 2px solid #ddd;
padding: 15px;
margin-bottom: 20px;
}
.email-header h1 {
margin: 0 0 15px 0;
font-size: 16pt;
color: #222;
}
.email-header table {
width: 100%;
border-collapse: collapse;
}
.email-header th {
text-align: left;
width: 60px;
padding: 3px 10px 3px 0;
color: #666;
font-weight: normal;
vertical-align: top;
}
.email-header td {
padding: 3px 0;
}
.email-body {
padding: 0 5px;
}
.email-body img {
max-width: 100%;
height: auto;
}
</style>
</head>
<body>
<div class="email-header">
<h1>${escapeHtml(email.subject || '(Kein Betreff)')}</h1>
<table>
<tr>
<th>Von:</th>
<td>${escapeHtml(fromDisplay)}</td>
</tr>
<tr>
<th>An:</th>
<td>${escapeHtml(toList.join(', '))}</td>
</tr>
<tr>
<th>Datum:</th>
<td>${formatDate(email.receivedAt)}</td>
</tr>
</table>
</div>
<div class="email-body">
${body}
</div>
</body>
</html>
`.trim();
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
// Alle anderen Tags entfernen
text = text.replace(/<[^>]+>/g, '');
// HTML-Entities dekodieren
text = text.replace(/&nbsp;/g, ' ');
text = text.replace(/&amp;/g, '&');
text = text.replace(/&lt;/g, '<');
text = text.replace(/&gt;/g, '>');
text = text.replace(/&quot;/g, '"');
text = text.replace(/&#39;/g, "'");
text = text.replace(/&auml;/g, 'ä');
text = text.replace(/&ouml;/g, 'ö');
text = text.replace(/&uuml;/g, 'ü');
text = text.replace(/&Auml;/g, 'Ä');
text = text.replace(/&Ouml;/g, 'Ö');
text = text.replace(/&Uuml;/g, 'Ü');
text = text.replace(/&szlig;/g, 'ß');
// Mehrfache Leerzeilen reduzieren
text = text.replace(/\n{3,}/g, '\n\n');
// Führende/folgende Leerzeichen entfernen
text = text.trim();
return text;
}