Files
paperlessmanager/paperless-backend/src/email/email-import.service.ts
T
bjoernpoettker ed57477324
Build and Push Multi-Platform Images / build-and-push (push) Successful in 31s
feat: importierte E-Mails automatisch in IMAP-Ordner verschieben und nach 90 Tagen löschen
- Neuer ImapFolderService verschiebt E-Mails nach erfolgreichem Import in den
  konfigurierbaren Ordner "importiert" (wird bei Bedarf automatisch erstellt)
- Täglicher Cron um 03:00 Uhr verschiebt E-Mails älter als 90 Tage in den
  Papierkorb und leert ihn anschließend
- createImapClient()-Hilfsmethode im EmailDownloadService ausgelagert
- IMAP_IMPORTED_FOLDER und IMAP_TRASH_FOLDER in docker-compose ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:58:41 +02:00

670 lines
22 KiB
TypeScript

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 { ImapFolderService } from './imap-folder.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);
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,
) {}
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 urlTemplate.replace('{Jahr}', year);
}
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';
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<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';
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<boolean> {
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<Buffer> {
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<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 ---
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;
}[];
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 = 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;
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,
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++) {
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);
// 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.`,
);
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);
}
}
}