Files
paperlessmanager/paperless-backend/src/email/email-import.service.ts
T
bjoernpoettkerandClaude Opus 5.5 fe338271bf fix(security): Symlinks im Scanner, Exportpfad und Belegnummer-URL absichern
- Scanner: Dateien von der Scan-Freigabe werden nur übernommen, wenn sie
  reguläre Dateien in echten Verzeichnissen sind. chokidar folgt keinen
  Symlinks mehr; beim Kopieren über Mount-Grenzen (/mnt/scans → /mnt/data)
  wird mit O_NOFOLLOW geöffnet und nur exklusiv geschrieben. Bisher holte
  ein Symlink wie scan.pdf -> /proc/self/environ die Zieldatei in den
  Posteingang.
- Export: Dateinamen (enthalten u.a. Barcode-Werte) werden auf ein
  Pfadsegment reduziert – „../“ verlässt den FTP-Zielordner nicht mehr.
- Belegnummer: {Jahr}/{Nummer} werden URL-kodiert in BELEGNUMMER_*_URL
  eingesetzt (Nummer stammt aus Request bzw. gedrucktem Barcode);
  belegnummer/release prüft die Typen im Body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:46:57 +01:00

950 lines
32 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.
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<string>();
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<Email>,
@InjectRepository(Attachment)
private readonly attachmentRepo: Repository<Attachment>,
@InjectRepository(Content)
private readonly contentRepo: Repository<Content>,
@InjectRepository(CorrespondentEmailMapping)
private readonly mappingRepo: Repository<CorrespondentEmailMapping>,
@InjectRepository(Task) private readonly taskRepo: Repository<Task>,
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<void> {
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<number | null> {
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<string> {
const urlTemplate = this.configService.get<string>('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<void> {
const urlTemplate = this.configService.get<string>(
'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<void> {
const urlTemplate = this.configService.get<string>('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<boolean> {
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<boolean> {
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<boolean> {
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<Buffer> {
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<Buffer> {
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<Buffer> {
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<string[]> {
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<string, number>,
jobId?: string,
): Promise<void> {
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<string, number> = {};
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);
}
}
}