- 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>
950 lines
32 KiB
TypeScript
950 lines
32 KiB
TypeScript
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);
|
||
}
|
||
}
|
||
}
|