import { Injectable, Logger, HttpException, HttpStatus, ConflictException, BadRequestException, } 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 { deriveTaskMetadata } from '../paperless/task-metadata.util'; import * as QRCode from 'qrcode'; import { EmailPageCacheService } from './email-page-cache.service'; import { ImapFolderService } from './imap-folder.service'; import { AttachmentFingerprintService } from './attachment-fingerprint.service'; import { DokumentVersionService } from '../paperless/dokument-version.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'; import { ersetzeUrlPlatzhalter } from '../common/pfad-sicherheit.util'; function sanitizeFilename(name: string): string { return name .replace(/[/\\]/g, '-') .replace(/\.\./g, '.') .replace(/\x00/g, '') .trim(); } @Injectable() export class EmailImportService { private readonly logger = new Logger(EmailImportService.name); // Anhänge (Checksumme + Seitenbereich), die gerade importiert werden private readonly laufendeImporte = new Set(); private readonly importJobs = new Map< string, { message: string; done: boolean } >(); private setJobStatus( jobId: string | undefined, message: string, done = false, ): void { if (!jobId) return; this.importJobs.set(jobId, { message, done }); } getJobStatus(jobId: string): { message: string; done: boolean } | null { return this.importJobs.get(jobId) ?? null; } 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, private readonly imapFolderService: ImapFolderService, private readonly fingerprints: AttachmentFingerprintService, private readonly dokumentVersion: DokumentVersionService, ) {} 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 ersetzeUrlPlatzhalter(urlTemplate, { Jahr: year }); } /** URL mit Jahr und Belegnummer – die Nummer kommt u.a. aus Barcodes. */ private buildNummerUrl( urlTemplate: string, dateStr: string, nummer: string, ): string { return ersetzeUrlPlatzhalter(this.buildUrl(urlTemplate, dateStr), { Nummer: nummer, }); } 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'; const url = this.buildNummerUrl(urlTemplate, emailDate, 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'; const url = this.buildNummerUrl(urlTemplate, emailDate, 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, ); } } // --- Duplikatprüfung --- /** * Prüft, ob ein Anhang (oder mit `pages` ein Teilbereich davon) schon * importiert wurde. Neben Paperless selbst zählen dabei auch Importe, die * gerade noch in Paperless verarbeitet werden: Deren Checksumme kennt * Paperless erst nach dem Consume, z.B. wenn dieselbe Mail zweimal * weitergeleitet wurde und die erste gerade importiert ist. */ async checkDuplicate( attachmentId: number, pages?: { start: number; end: number }, ): Promise { const attachment = await this.attachmentRepo.findOne({ where: { Id: attachmentId }, }); if (!attachment) return false; if (!pages) { if ( attachment.Checksum && (await this.hasPendingImport(attachment.Checksum, 'full')) ) { return true; } if (await this.hasTwinByContent(attachment, 'full')) return true; if (!attachment.Checksum) return false; return this.paperlessService.checksumExists(attachment.Checksum); } 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(); // Bereich genauso begrenzen wie executeImport, damit der Range-Key passt const start = Math.max(1, pages.start); const end = Math.min(pages.end === 999 ? total : pages.end, total); if ( attachment.Checksum && (await this.hasPendingImport(attachment.Checksum, `${start}-${end}`)) ) { return true; } if (await this.hasTwinByContent(attachment, `${start}-${end}`)) { return true; } const sliced = await PDFDocument.create(); const indices = Array.from( { length: end - start + 1 }, (_, i) => start - 1 + i, ); const copied = await sliced.copyPages(pdfDoc, indices); copied.forEach((p) => sliced.addPage(p)); // Paperless ≥ 3.0 vergleicht SHA-256-Checksummen const checksum = crypto .createHash('sha256') .update(Buffer.from(await sliced.save())) .digest('hex'); return this.paperlessService.checksumExists(checksum); } /** * Läuft für einen Anhang mit dieser Checksumme (egal aus welcher E-Mail) * bereits ein Import desselben Seitenbereichs? Fehlgeschlagene Tasks löscht * der Task-Processor, abgeschlossene findet Paperless selbst. */ private async hasPendingImport( checksum: string, range: string, ): Promise { const count = await this.taskRepo .createQueryBuilder('t') .innerJoin(Attachment, 'a', 'a.Id = t.SourceAttachmentID') .where('a.Checksum = :checksum', { checksum }) .andWhere('t.SourceAttachmentRange = :range', { range }) .andWhere('(t.Fertig IS NULL OR t.Fertig = 0)') .getCount(); return count > 0; } /** * Gleicher Inhalt, andere Datei: Absender erzeugen PDFs beim erneuten * Versand oft neu (andere Checksumme). Der Text-Fingerprint erkennt das. */ private async hasTwinByContent( attachment: Attachment, range: string, ): Promise { const fingerprint = await this.fingerprints.ensure(attachment); if (!fingerprint) return false; return this.fingerprints.hasImportedTwin(attachment.Id, fingerprint, range); } // --- Print Preview --- /** * PDF eines Anhangs, optional nur der Seitenbereich (1-basiert, `end: 999` * = bis zum Ende) – ohne Stempel. */ async getAttachmentPdf( attachmentId: number, pages?: { start: number; end: number }, ): Promise { const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: attachmentId }, }); if (!content) throw new HttpException('Inhalt nicht gefunden', HttpStatus.NOT_FOUND); if (!pages) return content.Content1; const pdfDoc = await PDFDocument.load(content.Content1, { ignoreEncryption: true, }); const total = pdfDoc.getPageCount(); const ende = pages.end === 999 ? total : Math.min(pages.end, total); if ( !Number.isInteger(pages.start) || !Number.isInteger(ende) || pages.start < 1 || pages.start > ende ) { throw new BadRequestException( `Ungültiger Seitenbereich ${pages.start}–${pages.end} (Dokument hat ${total} Seiten)`, ); } const startIdx = pages.start - 1; const endIdx = ende - 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)); return Buffer.from(await sliced.save()); } async generatePrintPdf( attachmentId: number, barcodeData: any, ): Promise { const { _pages, ...barcode } = barcodeData; const pdfBytes = await this.getAttachmentPdf( attachmentId, _pages as { start: number; end: number } | undefined, ); 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 --- /** * Prüft unmittelbar vor dem Upload – und damit bevor eine Belegnummer * festgelegt wird – nochmals auf Duplikate. Seit dem Öffnen des Assistenten * kann dasselbe Dokument aus einer anderen E-Mail importiert worden sein. */ async executeImport(data: { jobId?: string; attachments: { attachmentId: number; type: 'MAIN' | 'ATTACHMENT' | 'IGNORE'; paperlessCorrespondentId?: number | null; parentDocumentId?: number | null; splitRanges?: { start: number; end: number }[]; barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string; }; belegnummer?: string; /** Stammdokument in Paperless, an das der Beleg als neue Version kommt */ versionZuDokumentId?: number | null; }[]; emailDate: string; }): Promise<{ success: boolean; results: any[] }> { const sperren = await this.sperreImporte(data.attachments); try { return await this.runImport(data); } finally { sperren.forEach((key) => this.laufendeImporte.delete(key)); } } /** * Reserviert jeden zu importierenden Anhang (bzw. Seitenbereich) für diesen * Import und wirft einen ConflictException, wenn er schon vorhanden ist oder * parallel importiert wird. Die Reservierung schließt die Lücke zwischen * Prüfung und Upload, in der Paperless noch nichts davon weiß. */ private async sperreImporte( attachments: { attachmentId: number; type: string; splitRanges?: { start: number; end: number }[]; }[], ): Promise { const sperren: string[] = []; try { for (const att of attachments) { if (att.type === 'IGNORE') continue; const attachment = await this.attachmentRepo.findOne({ where: { Id: att.attachmentId }, }); if (!attachment) continue; const ranges = att.splitRanges?.length ? att.splitRanges : [undefined]; for (const range of ranges) { const bereich = range ? `${range.start}-${range.end}` : 'full'; // Fingerprint vor Checksumme: fängt auch neu erzeugte PDFs ab const fingerprint = await this.fingerprints.ensure(attachment); const key = `${fingerprint || attachment.Checksum || `id:${attachment.Id}`}:${bereich}`; const bezeichnung = range ? `„${attachment.FileName}" (Seiten ${bereich})` : `„${attachment.FileName}"`; // has + add ohne await dazwischen: atomar gegenüber parallelen Importen if (this.laufendeImporte.has(key)) { throw new ConflictException( `${bezeichnung} wird gerade bereits importiert.`, ); } this.laufendeImporte.add(key); sperren.push(key); if (await this.checkDuplicate(att.attachmentId, range)) { throw new ConflictException( `${bezeichnung} ist bereits in Paperless vorhanden oder wird dort gerade verarbeitet.`, ); } } } return sperren; } catch (err) { sperren.forEach((key) => this.laufendeImporte.delete(key)); throw err; } } /** * Hängt jede Upload-Datei als neue Version an das Stammdokument. Kein Task, * keine Belegnummer-Bestätigung – Metadaten des Dokuments bleiben unverändert. */ private async importiereAlsVersion( documentId: number, belegnummer: string, uploadItems: { path: string; filename: string; rangeKey: string }[], paperlessIds: Record, jobId?: string, ): Promise { for (const item of uploadItems) { this.setJobStatus( jobId, `Hänge ${item.filename} als neue Version an Dokument ${documentId}...`, ); await this.dokumentVersion.haengeVersionAn( documentId, item.path, belegnummer, ); paperlessIds[item.rangeKey] = documentId; } } private async runImport(data: { jobId?: string; attachments: { attachmentId: number; type: 'MAIN' | 'ATTACHMENT' | 'IGNORE'; paperlessCorrespondentId?: number | null; parentDocumentId?: number | null; splitRanges?: { start: number; end: number }[]; barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string; }; belegnummer?: string; /** Stammdokument in Paperless, an das der Beleg als neue Version kommt */ versionZuDokumentId?: number | null; }[]; emailDate: string; }): Promise<{ success: boolean; results: any[] }> { const tempDir = await fs.mkdtemp( path.join(os.tmpdir(), 'paperless-mail-import-'), ); const results = []; this.setJobStatus(data.jobId, 'Dokumente werden vorbereitet...'); 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 = sanitizeFilename( attachmentEntity.FileName.replace(/\.pdf$/i, ''), ); const paperlessIds: Record = {}; 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', }); } // Vom Benutzer bestätigt: als neue Version an das vorhandene Dokument if (att.versionZuDokumentId) { await this.importiereAlsVersion( att.versionZuDokumentId, att.belegnummer ?? '', uploadPromises, paperlessIds, data.jobId, ); attachmentEntity.PaperlessDocumentIds = paperlessIds; attachmentEntity.ImportStatus = 1; if (att.belegnummer) { attachmentEntity.InterneBelegnummer = att.belegnummer; } await this.attachmentRepo.save(attachmentEntity); results.push({ attachmentId: att.attachmentId, paperlessIds, alsVersion: true, }); continue; } // 0. Check if ASN already exists if (att.belegnummer) { await this.paperlessService.validateAsnNotExists(att.belegnummer); } // Upload all generated PDFs for (const uploadItem of uploadPromises) { // Einmal definieren, zweimal verwenden: Upload-Metadaten und Task // tragen garantiert dieselben Werte const taskFields = { InterneBelegnummer: att.belegnummer || '', Eingangsdatum: att.barcode?.datum ? new Date(att.barcode.datum) : createdDate, Belegdatum: createdDate, DocumentType: att.type === 'MAIN' ? null : 5, // 5 = Anlage BetriebID: null, // Owner-Entfernung erledigt der Task-Processor }; // Metadaten direkt beim Upload mitgeben; der Task-Processor patcht // später idempotent nach (Sicherheitsnetz) const derived = deriveTaskMetadata(taskFields); const options: any = { filename: uploadItem.filename, title: att.belegnummer ? `Beleg ${att.belegnummer}` : uploadItem.filename, created: createdDate, documentType: derived.documentType, archiveSerialNumber: derived.archiveSerialNumber, customFields: derived.customFields, }; if (att.paperlessCorrespondentId) options.correspondent = att.paperlessCorrespondentId; this.setJobStatus(data.jobId, `Lade ${uploadItem.filename} hoch...`); 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, ...taskFields, BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null, Fertig: 0, 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++) { this.setJobStatus( data.jobId, `Warte auf Paperless-Verarbeitung... (${i + 1}/30)`, ); await new Promise((resolve) => setTimeout(resolve, 2000)); try { const taskStatus = await this.paperlessService.getTask(paperlessTaskId); if (taskStatus?.documentId) { docId = taskStatus.documentId; break; } if ( taskStatus?.status === 'FAILURE' || taskStatus?.status === 'REVOKED' ) { this.logger.warn( `Paperless-Task ${paperlessTaskId} fehlgeschlagen: ${taskStatus.message ?? 'keine Meldung'}`, ); 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.`, ); const emailEntity = await this.emailRepo.findOne({ where: { Id: firstAtt.EmailMessageId }, }); if (emailEntity) { this.imapFolderService .moveToImportiert(emailEntity.MessageId) .catch((err) => this.logger.error( 'IMAP-Verschieben fehlgeschlagen: ' + err.message, ), ); } } } this.setJobStatus(data.jobId, 'Import abgeschlossen', true); return { success: true, results }; } finally { // Clean up temp dir and job status await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {}); if (data.jobId) setTimeout(() => this.importJobs.delete(data.jobId!), 5000); } } }