import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import axios from 'axios'; import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; import { Attachment } from '../database/entities/attachment.entity'; import { Email } from '../database/entities/email.entity'; import { Content } from '../database/entities/content.entity'; import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity'; import { Task } from '../database/entities/task.entity'; import { PaperlessService } from '../paperless/paperless.service'; import * as QRCode from 'qrcode'; import { EmailPageCacheService } from './email-page-cache.service'; import { PdfService } from '../preprocessing/pdf.service'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs/promises'; import * as crypto from 'crypto'; @Injectable() export class EmailImportService { private readonly logger = new Logger(EmailImportService.name); constructor( private readonly configService: ConfigService, @InjectRepository(Email) private readonly emailRepo: Repository, @InjectRepository(Attachment) private readonly attachmentRepo: Repository, @InjectRepository(Content) private readonly contentRepo: Repository, @InjectRepository(CorrespondentEmailMapping) private readonly mappingRepo: Repository, @InjectRepository(Task) private readonly taskRepo: Repository, private readonly paperlessService: PaperlessService, private readonly pdfService: PdfService, private readonly pageCache: EmailPageCacheService, ) {} async ensurePreviews(emailId: number): Promise { const attachments = await this.attachmentRepo.find({ where: { EmailMessageId: emailId, ContentType: 'application/pdf' }, relations: ['Content'], }); for (const attachment of attachments) { const hasPreview = await this.pageCache.hasPreview(attachment.Id, 1); if (!hasPreview && attachment.Content?.Content1) { this.logger.log(`Generiere fehlende Vorschaubilder für Anhang ${attachment.Id} (Email ${emailId})`); const tempPdfPath = path.join(os.tmpdir(), `email-att-gen-${attachment.Id}.pdf`); try { await fs.writeFile(tempPdfPath, attachment.Content.Content1); const images = await this.pdfService.pdfToImages(tempPdfPath, 400); await this.pageCache.generate(attachment.Id, images); attachment.PageCount = images.length; await this.attachmentRepo.save(attachment); await this.pdfService.cleanup(images); } catch (err: any) { this.logger.warn(`Fehler bei on-demand Vorschau-Generierung für Anhang ${attachment.Id}: ${err.message}`); } finally { await fs.unlink(tempPdfPath).catch(() => {}); } } } } // --- Korrespondenten Mapping --- async getMappings() { return this.mappingRepo.find(); } async addMapping(emailAddress: string, paperlessCorrespondentId: number) { let mapping = await this.mappingRepo.findOne({ where: { EmailAddress: emailAddress } }); if (!mapping) { mapping = this.mappingRepo.create({ EmailAddress: emailAddress, PaperlessCorrespondentId: paperlessCorrespondentId }); } else { mapping.PaperlessCorrespondentId = paperlessCorrespondentId; } return this.mappingRepo.save(mapping); } async deleteMapping(id: number) { return this.mappingRepo.delete(id); } async getCorrespondentByEmail(emailAddress: string): Promise { const mapping = await this.mappingRepo.findOne({ where: { EmailAddress: emailAddress } }); return mapping ? mapping.PaperlessCorrespondentId : null; } // --- Belegnummern API --- private buildUrl(urlTemplate: string, dateStr: string): string { const dateObj = new Date(dateStr); const year = (isNaN(dateObj.getTime()) ? new Date() : dateObj).getFullYear().toString(); return urlTemplate.replace('{Jahr}', year); } async getBelegnummer(emailDate: string): Promise { const urlTemplate = this.configService.get('BELEGNUMMER_GET_URL'); if (!urlTemplate) throw new HttpException('BELEGNUMMER_GET_URL not configured', HttpStatus.INTERNAL_SERVER_ERROR); const url = this.buildUrl(urlTemplate, emailDate); try { this.logger.debug(`Fetching Belegnummer from ${url}`); const response = await axios.get(url); // If the response is an object, try to extract 'nummer' or 'number' let result = response.data; if (result && typeof result === 'object') { result = result.nummer || result.number || result.data?.nummer || JSON.stringify(result); } this.logger.debug(`Received Belegnummer: ${result}`); return String(result); } catch (error: any) { const status = error.response?.status || 'UNKNOWN'; const detail = error.response?.data ? JSON.stringify(error.response.data) : error.message; this.logger.error(`Failed to fetch Belegnummer from ${url}. Status: ${status}, Detail: ${detail}`); throw new HttpException(`Fehler beim Abrufen der Belegnummer: ${detail}`, HttpStatus.BAD_GATEWAY); } } async releaseBelegnummer(emailDate: string, number: string): Promise { const urlTemplate = this.configService.get('BELEGNUMMER_RELEASE_URL'); if (!urlTemplate) { this.logger.warn('BELEGNUMMER_RELEASE_URL not configured, skipping release.'); return; } const cleanNumber = number.replace(/^0+/, '') || '0'; let url = this.buildUrl(urlTemplate, emailDate); url = url.replace('{Nummer}', cleanNumber); try { this.logger.log(`Releasing Belegnummer: ${cleanNumber} (original: ${number}) via ${url}`); await axios.get(url); } catch (error: any) { this.logger.error(`Failed to release Belegnummer at ${url}: ${error.message}`); } } async setBelegnummer(emailDate: string, number: string): Promise { const urlTemplate = this.configService.get('BELEGNUMMER_SET_URL'); if (!urlTemplate) throw new HttpException('BELEGNUMMER_SET_URL not configured', HttpStatus.INTERNAL_SERVER_ERROR); const cleanNumber = number.replace(/^0+/, '') || '0'; let url = this.buildUrl(urlTemplate, emailDate); url = url.replace('{Nummer}', cleanNumber); try { this.logger.log(`Setting Belegnummer: ${cleanNumber} (original: ${number}) via ${url}`); await axios.get(url); } catch (error: any) { this.logger.error(`Failed to set Belegnummer at ${url}: ${error.message}`); throw new HttpException('Fehler beim Setzen der Belegnummer', HttpStatus.BAD_GATEWAY); } } // --- Checksum Check for Split Documents --- async checkSplitChecksum(attachmentId: number, pages: { start: number; end: number }): Promise { const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: attachmentId } }); if (!content) return false; const pdfDoc = await PDFDocument.load(content.Content1, { ignoreEncryption: true }); const total = pdfDoc.getPageCount(); const startIdx = Math.max(1, pages.start) - 1; const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1; const sliced = await PDFDocument.create(); const indices = Array.from({ length: endIdx - startIdx + 1 }, (_, i) => startIdx + i); const copied = await sliced.copyPages(pdfDoc, indices); copied.forEach(p => sliced.addPage(p)); const checksum = crypto.createHash('md5').update(Buffer.from(await sliced.save())).digest('hex'); return this.paperlessService.checksumExists(checksum); } // --- Print Preview --- async generatePrintPdf(attachmentId: number, barcodeData: any): Promise { const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: attachmentId } }); if (!content) throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND); let pdfBytes: Buffer = content.Content1; const pages: { start: number; end: number } | undefined = barcodeData._pages; if (pages) { const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }); const total = pdfDoc.getPageCount(); const startIdx = Math.max(1, pages.start) - 1; const endIdx = Math.min(pages.end === 999 ? total : pages.end, total) - 1; const sliced = await PDFDocument.create(); const indices = Array.from({ length: endIdx - startIdx + 1 }, (_, i) => startIdx + i); const copied = await sliced.copyPages(pdfDoc, indices); copied.forEach(p => sliced.addPage(p)); pdfBytes = Buffer.from(await sliced.save()); } const { _pages, ...barcode } = barcodeData; return this.applyBarcodeToPdf(pdfBytes, barcode); } async applyBarcodeToPdf(pdfBytes: Buffer, barcodeData: any): Promise { this.logger.debug(`applyBarcodeToPdf: Input size = ${pdfBytes.length} bytes`); let currentPdfBytes = pdfBytes; const tempInputPath = path.join(os.tmpdir(), `input-${Date.now()}.pdf`); await fs.writeFile(tempInputPath, pdfBytes); try { // First try to load to check encryption let pdfDoc = await PDFDocument.load(currentPdfBytes, { ignoreEncryption: true }); if (pdfDoc.isEncrypted) { this.logger.log('PDF ist verschlüsselt, versuche Bereinigung via Ghostscript...'); const sanitizedPath = await this.pdfService.sanitizePdf(tempInputPath); currentPdfBytes = await fs.readFile(sanitizedPath); await fs.unlink(sanitizedPath).catch(() => {}); // Reload sanitized PDF pdfDoc = await PDFDocument.load(currentPdfBytes); } const pages = pdfDoc.getPages(); this.logger.debug(`applyBarcodeToPdf: Pages = ${pages.length}, Encrypted = ${pdfDoc.isEncrypted}`); if (pages.length === 0) { this.logger.warn('applyBarcodeToPdf: Keine Seiten gefunden'); return Buffer.from(await pdfDoc.save()); } const firstPage = pages[0]; const { x, y, nummer, datum, jahr } = barcodeData; // Parse date const d = new Date(datum); const yyyy = (isNaN(d.getTime()) ? new Date() : d).getFullYear().toString(); const mm = String((isNaN(d.getTime()) ? new Date() : d).getMonth() + 1).padStart(2, '0'); const dd = String((isNaN(d.getTime()) ? new Date() : d).getDate()).padStart(2, '0'); const qrDateStr = `${yyyy}${mm}${dd}`; // yyyyMMdd const qrContent = `${String(jahr).padStart(4, '0')}-${String(nummer).padStart(6, '0')}-${qrDateStr}`; const printDateStr = `${dd}.${mm}.${yyyy}`; // Dimensions: 57x32 mm const PT_PER_MM = 2.83465; const boxW = 57 * PT_PER_MM; const boxH = 32 * PT_PER_MM; // A4 dimensions: 210x297 mm const PAGE_H_PT = 297 * PT_PER_MM; // Convert mm to points (Y is from bottom in pdf-lib) const startX = Number(x) * PT_PER_MM; const startY = PAGE_H_PT - (Number(y) * PT_PER_MM) - boxH; // 1. Draw Background Box (White with Black border) firstPage.drawRectangle({ x: startX, y: startY, width: boxW, height: boxH, color: rgb(1, 1, 1), borderColor: rgb(0, 0, 0), borderWidth: 1, }); // 2. Draw QR Code const qrBuffer = await QRCode.toBuffer(qrContent, { errorCorrectionLevel: 'H', margin: 0, width: 300, color: { dark: '#000000', light: '#FFFFFF' } }); const qrImage = await pdfDoc.embedPng(qrBuffer); // QR Code size: 27x27 mm (10% smaller than 30x30) const qrSize = 27 * PT_PER_MM; const padding = (32 - 27) / 2; // Center vertically in 32mm box const qrX = startX + (padding * PT_PER_MM); const qrY = startY + (padding * PT_PER_MM); firstPage.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize, }); // 3. Draw Texts const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); // Helper to draw centered text in a specific area const drawCenteredInArea = (text: string, relX: number, relY: number, areaW: number, areaH: number, fontSize: number) => { const textWidth = helveticaBold.widthOfTextAtSize(text, fontSize); const absX = startX + (relX * PT_PER_MM) + (areaW * PT_PER_MM / 2) - (textWidth / 2); const absY = (startY + boxH) - (relY * PT_PER_MM) - (areaH * PT_PER_MM / 2) - (fontSize / 2.5); firstPage.drawText(text, { x: absX, y: absY, size: fontSize, font: helveticaBold, color: rgb(0, 0, 0), }); }; const isNeu = barcodeData.isNeu === true; // Text Area X: +33.3mm, Width: 21mm // Year: Y + 3mm, Height: 7.5mm drawCenteredInArea(String(jahr).padStart(4, '0'), 33.3, 3, 21, 7.5, 12); // Number: Y + 10.5mm, Height: 7.5mm const numberText = isNeu ? '<- neu ->' : String(nummer).padStart(6, '0'); drawCenteredInArea(numberText, 33.3, 10.5, 21, 7.5, 12); // "Eingegangen": Y + 19mm, Height: 4mm, Size 8 drawCenteredInArea('Eingegangen', 33.3, 19, 21, 4, 8); // Date: Y + 19 + 3.5 = 22.5mm, Height: 4mm, Size 8 drawCenteredInArea(printDateStr, 33.3, 22.5, 21, 4, 8); return Buffer.from(await pdfDoc.save()); } finally { await fs.unlink(tempInputPath).catch(() => {}); } } // --- Import Logic --- async executeImport(data: { attachments: { attachmentId: number; type: 'MAIN' | 'ATTACHMENT' | 'IGNORE'; paperlessCorrespondentId?: number | null; parentDocumentId?: number | null; // Used if type is ATTACHMENT (should map to a Custom Field theoretically, or just tags. For now, CF if configured, but we pass it) splitRanges?: { start: number; end: number }[]; // 1-based pages, e.g. [{start: 1, end: 3}, {start: 4, end: 5}] barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string }; belegnummer?: string; }[]; emailDate: string; }): Promise<{ success: boolean; results: any[] }> { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'paperless-mail-import-')); const results = []; try { for (const att of data.attachments) { if (att.type === 'IGNORE') continue; const attachmentEntity = await this.attachmentRepo.findOne({ where: { Id: att.attachmentId } }); if (!attachmentEntity) continue; const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: att.attachmentId } }); if (!content) continue; const originalPdfBytes = content.Content1; const baseFilename = attachmentEntity.FileName.replace(/\.pdf$/i, ''); const paperlessIds: any = {}; const uploadPromises = []; // Formatting the date for Paperless (ISO format) const createdDate = new Date(data.emailDate).toISOString(); if (att.splitRanges && att.splitRanges.length > 0) { // SPLIT PDF const pdfDoc = await PDFDocument.load(originalPdfBytes, { ignoreEncryption: true }); const totalPages = pdfDoc.getPageCount(); for (const range of att.splitRanges) { const start = Math.max(1, range.start); const end = Math.min(range.end, totalPages); if (start > end) { this.logger.warn(`Ungültiger Bereich für Splitting: ${start}-${end} (Seiten gesamt: ${totalPages})`); continue; } const newPdf = await PDFDocument.create(); // Pages are 0-indexed in pdf-lib const pageIndices = Array.from({ length: end - start + 1 }, (_, i) => start - 1 + i); const copiedPages = await newPdf.copyPages(pdfDoc, pageIndices); copiedPages.forEach((p) => newPdf.addPage(p)); const splitPdfBytes = await newPdf.save(); const tempFilePath = path.join(tempDir, `${baseFilename}_${start}-${end}.pdf`); await fs.writeFile(tempFilePath, Buffer.from(splitPdfBytes)); uploadPromises.push({ path: tempFilePath, filename: `${baseFilename}_${start}-${end}`, rangeKey: `${start}-${end}`, }); } } else { // Process Full Attachment const tempFilePath = path.join(tempDir, `${baseFilename}.pdf`); await fs.writeFile(tempFilePath, originalPdfBytes); uploadPromises.push({ path: tempFilePath, filename: baseFilename, rangeKey: 'full', }); } // 0. Check if ASN already exists if (att.belegnummer) { await this.paperlessService.validateAsnNotExists(att.belegnummer); } // Upload all generated PDFs for (const uploadItem of uploadPromises) { const options: any = { filename: uploadItem.filename, title: att.belegnummer ? `Beleg ${att.belegnummer}` : uploadItem.filename, created: createdDate, owner: null, }; if (att.paperlessCorrespondentId) options.correspondent = att.paperlessCorrespondentId; const paperlessTaskId = await this.paperlessService.uploadDocument(uploadItem.path, options); // Create background task for enrichment (same logic as Inbox) const backgroundTask = this.taskRepo.create({ TaskId: paperlessTaskId, InterneBelegnummer: att.belegnummer || '', Eingangsdatum: att.barcode?.datum ? new Date(att.barcode.datum) : createdDate, Belegdatum: createdDate, BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null, BetriebID: null, // Owner Fertig: 0, DocumentType: att.type === 'MAIN' ? null : 5, // 5 = Anlage SourceAttachmentID: att.attachmentId, SourceAttachmentRange: uploadItem.rangeKey, }); await this.taskRepo.save(backgroundTask); // Still poll for Doc ID so we can return it to the frontend for immediate preview let docId = null; for (let i = 0; i < 30; i++) { await new Promise(resolve => setTimeout(resolve, 2000)); try { const taskStatus = await this.paperlessService.getTask(paperlessTaskId); // Paperless returns { results: [ ... ] } for filtered tasks const statusObj = taskStatus.results ? taskStatus.results[0] : (Array.isArray(taskStatus) ? taskStatus[0] : taskStatus); if (statusObj && statusObj.related_document) { docId = statusObj.related_document; break; } } catch(e) {} } if (docId) { paperlessIds[uploadItem.rangeKey] = docId; } } // Update Database attachmentEntity.PaperlessDocumentIds = paperlessIds; attachmentEntity.ImportStatus = 1; if (att.belegnummer) { attachmentEntity.InterneBelegnummer = att.belegnummer; } await this.attachmentRepo.save(attachmentEntity); // Confirm Belegnummer if used if (att.belegnummer && att.barcode?.nummer) { await this.setBelegnummer(data.emailDate, att.barcode.nummer).catch(e => this.logger.warn(`Failed to set Belegnummer: ${e.message}`)); } results.push({ attachmentId: att.attachmentId, paperlessIds }); } // Mark Email as processed (Status = 1) if (data.attachments.length > 0) { const firstAtt = await this.attachmentRepo.findOne({ where: { Id: data.attachments[0].attachmentId } }); if (firstAtt) { await this.emailRepo.update(firstAtt.EmailMessageId, { Status: 1 }); this.logger.log(`Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`); } } return { success: true, results }; } finally { // Clean up temp dir await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); } } }