Initial commit with Email Import Wizard and Task Processor updates

This commit is contained in:
2026-05-04 08:02:11 +02:00
commit effdc5d59f
170 changed files with 67739 additions and 0 deletions
@@ -0,0 +1,134 @@
import { Controller, Get, Post, Body, Param, Query, Res, HttpException, HttpStatus, Logger, Delete } from '@nestjs/common';
import type { Response } from 'express';
import { EmailImportService } from './email-import.service';
import { EmailPageCacheService } from './email-page-cache.service';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
@Controller('api/email-import')
export class EmailImportController {
private readonly logger = new Logger(EmailImportController.name);
constructor(
private readonly importService: EmailImportService,
private readonly pageCache: EmailPageCacheService,
) {}
// --- Korrespondenten Mapping ---
@Get('mappings')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async getMappings() {
return this.importService.getMappings();
}
@Post('mappings')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async addMapping(@Body() body: { emailAddress: string; paperlessCorrespondentId: number }) {
if (!body.emailAddress || !body.paperlessCorrespondentId) {
throw new HttpException('Missing emailAddress or paperlessCorrespondentId', HttpStatus.BAD_REQUEST);
}
return this.importService.addMapping(body.emailAddress, body.paperlessCorrespondentId);
}
@Delete('mappings/:id')
@RequirePermissions(Permission.MANAGE_SETTINGS)
async deleteMapping(@Param('id') id: string) {
return this.importService.deleteMapping(parseInt(id, 10));
}
@Get('correspondent')
@RequirePermissions(Permission.VIEW_MAIL)
async getCorrespondent(@Query('email') emailAddress: string) {
const id = await this.importService.getCorrespondentByEmail(emailAddress);
return { paperlessCorrespondentId: id };
}
@Post('emails/:id/ensure-previews')
@RequirePermissions(Permission.VIEW_MAIL)
async ensurePreviews(@Param('id') id: string) {
await this.importService.ensurePreviews(parseInt(id, 10));
return { success: true };
}
// --- Belegnummern ---
@Get('belegnummer')
@RequirePermissions(Permission.VIEW_MAIL)
async getBelegnummer(@Query('date') date: string) {
if (!date) throw new HttpException('Date query parameter required', HttpStatus.BAD_REQUEST);
const nummer = await this.importService.getBelegnummer(date);
return { nummer };
}
@Post('belegnummer/release')
@RequirePermissions(Permission.VIEW_MAIL)
async releaseBelegnummer(@Body() body: { date: string; number: string }) {
if (!body.date || !body.number) throw new HttpException('Date and number required', HttpStatus.BAD_REQUEST);
await this.importService.releaseBelegnummer(body.date, body.number);
return { success: true };
}
// --- Print Preview ---
@Post('attachments/:attachmentId/print-preview')
@RequirePermissions(Permission.VIEW_MAIL)
async printPreview(
@Param('attachmentId') attachmentId: number,
@Body() barcodeData: any,
@Res() res: Response,
) {
try {
const pdfBuffer = await this.importService.generatePrintPdf(attachmentId, barcodeData);
this.logger.log(`Print preview generated for attachment ${attachmentId}: ${pdfBuffer.length} bytes`);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="preview-${attachmentId}.pdf"`);
res.send(pdfBuffer);
} catch (err: any) {
this.logger.error(`Error generating print preview: ${err.message}`);
throw new HttpException(err.message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
// --- Page Thumbnails ---
@Get('attachments/:attachmentId/pages/:page/thumb')
@RequirePermissions(Permission.VIEW_MAIL)
async getPageThumbnail(
@Param('attachmentId') attachmentId: number,
@Param('page') page: number,
@Res() res: Response,
) {
const hasPreview = await this.pageCache.hasPreview(attachmentId, page);
if (!hasPreview) {
throw new HttpException('Preview not found', HttpStatus.NOT_FOUND);
}
const thumbPath = this.pageCache.thumbnailPath(attachmentId, page);
res.sendFile(thumbPath);
}
// --- Page Previews (larger) ---
@Get('attachments/:attachmentId/pages/:page/preview')
@RequirePermissions(Permission.VIEW_MAIL)
async getPagePreview(
@Param('attachmentId') attachmentId: number,
@Param('page') page: number,
@Res() res: Response,
) {
const hasPreview = await this.pageCache.hasPreview(attachmentId, page);
if (!hasPreview) {
throw new HttpException('Preview not found', HttpStatus.NOT_FOUND);
}
const previewPath = this.pageCache.previewPath(attachmentId, page);
res.sendFile(previewPath);
}
// --- Final Import ---
@Post('execute')
@RequirePermissions(Permission.MANAGE_ALL)
async executeImport(@Body() importData: any) {
try {
const result = await this.importService.executeImport(importData);
return result;
} catch (err: any) {
this.logger.error(`Error executing import: ${err.message}`);
throw new HttpException(err.message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
@@ -0,0 +1,446 @@
import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import axios from 'axios';
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
import { Attachment } from '../database/entities/attachment.entity';
import { Email } from '../database/entities/email.entity';
import { Content } from '../database/entities/content.entity';
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
import { Task } from '../database/entities/task.entity';
import { PaperlessService } from '../paperless/paperless.service';
import * as QRCode from 'qrcode';
import { EmailPageCacheService } from './email-page-cache.service';
import { PdfService } from '../preprocessing/pdf.service';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs/promises';
@Injectable()
export class EmailImportService {
private readonly logger = new Logger(EmailImportService.name);
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,
) {}
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})`);
try {
const tempPdfPath = path.join(os.tmpdir(), `email-att-gen-${attachment.Id}.pdf`);
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);
await fs.unlink(tempPdfPath).catch(() => {});
} catch (err: any) {
this.logger.warn(`Fehler bei on-demand Vorschau-Generierung für Anhang ${attachment.Id}: ${err.message}`);
}
}
}
}
// --- 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);
}
}
// --- 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);
return this.applyBarcodeToPdf(content.Content1, barcodeData);
}
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: {
attachments: {
attachmentId: number;
type: 'MAIN' | 'ATTACHMENT' | 'IGNORE';
paperlessCorrespondentId?: number | null;
parentDocumentId?: number | null; // Used if type is ATTACHMENT (should map to a Custom Field theoretically, or just tags. For now, CF if configured, but we pass it)
splitRanges?: { start: number; end: number }[]; // 1-based pages, e.g. [{start: 1, end: 3}, {start: 4, end: 5}]
barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string };
belegnummer?: string;
}[];
emailDate: string;
}): Promise<{ success: boolean; results: any[] }> {
const fs = require('fs/promises');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'paperless-mail-import-'));
const results = [];
try {
for (const att of data.attachments) {
if (att.type === 'IGNORE') continue;
const attachmentEntity = await this.attachmentRepo.findOne({ where: { Id: att.attachmentId } });
if (!attachmentEntity) continue;
const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: att.attachmentId } });
if (!content) continue;
const originalPdfBytes = content.Content1;
const baseFilename = attachmentEntity.FileName.replace(/\.pdf$/i, '');
const paperlessIds: any = {};
const uploadPromises = [];
// Formatting the date for Paperless (ISO format)
const createdDate = new Date(data.emailDate).toISOString();
if (att.splitRanges && att.splitRanges.length > 0) {
// SPLIT PDF
const pdfDoc = await PDFDocument.load(originalPdfBytes, { ignoreEncryption: true });
for (const range of att.splitRanges) {
const newPdf = await PDFDocument.create();
// Pages are 0-indexed in pdf-lib
const pageIndices = Array.from({ length: range.end - range.start + 1 }, (_, i) => range.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}_${range.start}-${range.end}.pdf`);
await fs.writeFile(tempFilePath, Buffer.from(splitPdfBytes));
uploadPromises.push({
path: tempFilePath,
filename: `${baseFilename}_${range.start}-${range.end}`,
rangeKey: `${range.start}-${range.end}`,
});
}
} else {
// Process Full Attachment
const tempFilePath = path.join(tempDir, `${baseFilename}.pdf`);
await fs.writeFile(tempFilePath, originalPdfBytes);
uploadPromises.push({
path: tempFilePath,
filename: baseFilename,
rangeKey: 'full',
});
}
// 0. Check if ASN already exists
if (att.belegnummer) {
await this.paperlessService.validateAsnNotExists(att.belegnummer);
}
// Upload all generated PDFs
for (const uploadItem of uploadPromises) {
const options: any = {
filename: uploadItem.filename,
title: att.belegnummer ? `Beleg ${att.belegnummer}` : uploadItem.filename,
created: createdDate,
owner: null,
};
if (att.paperlessCorrespondentId) options.correspondent = att.paperlessCorrespondentId;
const paperlessTaskId = await this.paperlessService.uploadDocument(uploadItem.path, options);
// Create background task for enrichment (same logic as Inbox)
const backgroundTask = this.taskRepo.create({
TaskId: paperlessTaskId,
InterneBelegnummer: att.belegnummer || '',
Eingangsdatum: att.barcode?.datum ? new Date(att.barcode.datum) : createdDate,
Belegdatum: createdDate,
BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null,
BetriebID: null, // Owner
Fertig: 0,
DocumentType: att.type === 'MAIN' ? null : 5, // 5 = Anlage
SourceAttachmentID: att.attachmentId,
SourceAttachmentRange: uploadItem.rangeKey,
});
await this.taskRepo.save(backgroundTask);
// Still poll for Doc ID so we can return it to the frontend for immediate preview
let docId = null;
for (let i = 0; i < 30; i++) {
await new Promise(resolve => setTimeout(resolve, 2000));
try {
const taskStatus = await this.paperlessService.getTask(paperlessTaskId);
const statusObj = Array.isArray(taskStatus) ? taskStatus[0] : taskStatus;
if (statusObj && statusObj.related_document) {
docId = statusObj.related_document;
break;
}
} catch(e) {}
}
if (docId) {
paperlessIds[uploadItem.rangeKey] = docId;
}
}
// Update Database
attachmentEntity.PaperlessDocumentIds = paperlessIds;
attachmentEntity.ImportStatus = 1;
if (att.belegnummer) {
attachmentEntity.InterneBelegnummer = att.belegnummer;
}
await this.attachmentRepo.save(attachmentEntity);
// Confirm Belegnummer if used
if (att.belegnummer && att.barcode?.nummer) {
await this.setBelegnummer(data.emailDate, att.barcode.nummer).catch(e => this.logger.warn(`Failed to set Belegnummer: ${e.message}`));
}
results.push({ attachmentId: att.attachmentId, paperlessIds });
}
// Mark Email as processed (Status = 1)
if (data.attachments.length > 0) {
const firstAtt = await this.attachmentRepo.findOne({
where: { Id: data.attachments[0].attachmentId }
});
if (firstAtt) {
await this.emailRepo.update(firstAtt.EmailMessageId, { Status: 1 });
this.logger.log(`Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`);
}
}
return { success: true, results };
} finally {
// Clean up temp dir
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
}
@@ -0,0 +1,57 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as path from 'path';
import * as fs from 'fs/promises';
import sharp from 'sharp';
const THUMBNAIL_WIDTH = 180;
@Injectable()
export class EmailPageCacheService {
private readonly logger = new Logger(EmailPageCacheService.name);
private readonly mailsRoot: string;
constructor(configService: ConfigService) {
this.mailsRoot = configService.get<string>('MAILS_DATA_DIR', '/mnt/data/mails');
}
attachmentDir(attachmentId: number | string): string {
return path.join(this.mailsRoot, attachmentId.toString());
}
previewPath(attachmentId: number | string, page: number): string {
return path.join(this.attachmentDir(attachmentId), `page-${page}.preview.png`);
}
thumbnailPath(attachmentId: number | string, page: number): string {
return path.join(this.attachmentDir(attachmentId), `page-${page}.thumb.png`);
}
async generate(attachmentId: number | string, renderedImages: string[]): Promise<void> {
const dir = this.attachmentDir(attachmentId);
await fs.mkdir(dir, { recursive: true });
for (let i = 0; i < renderedImages.length; i++) {
const page = i + 1;
const src = renderedImages[i];
const previewDest = this.previewPath(attachmentId, page);
const thumbDest = this.thumbnailPath(attachmentId, page);
try {
await fs.copyFile(src, previewDest);
await sharp(src).resize({ width: THUMBNAIL_WIDTH }).png().toFile(thumbDest);
} catch (err: any) {
this.logger.warn(`E-Mail Page Cache fehlgeschlagen (Attachment ${attachmentId} Seite ${page}): ${err.message}`);
}
}
}
async hasPreview(attachmentId: number | string, page: number): Promise<boolean> {
try {
await fs.access(this.previewPath(attachmentId, page));
return true;
} catch {
return false;
}
}
}
@@ -0,0 +1,55 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { EmailController } from './email.controller';
import { Email } from '../database/entities/email.entity';
const mockEmails: Partial<Email>[] = [
{ Id: 1, MessageId: 'msg-1', SenderAddress: 'a@test.de', RecipientAddress: 'b@test.de', Subject: 'Test 1', Date: new Date(), Body: 'body', Status: 0 },
{ Id: 2, MessageId: 'msg-2', SenderAddress: 'c@test.de', RecipientAddress: 'd@test.de', Subject: 'Test 2', Date: new Date(), Body: 'body2', Status: 1 },
];
const mockQueryBuilder = {
orderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(mockEmails),
};
const mockRepo = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
findOneByOrFail: jest.fn().mockResolvedValue(mockEmails[0]),
};
describe('EmailController', () => {
let controller: EmailController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [EmailController],
providers: [{ provide: getRepositoryToken(Email), useValue: mockRepo }],
}).compile();
controller = module.get<EmailController>(EmailController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('getEmails returns list', async () => {
const result = await controller.getEmails();
expect(result).toHaveLength(2);
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('e.Date', 'DESC');
});
it('getEmails filters by status', async () => {
await controller.getEmails('1');
expect(mockQueryBuilder.where).toHaveBeenCalledWith('e.Status = :status', { status: 1 });
});
it('getEmail returns single item', async () => {
const result = await controller.getEmail('1');
expect(result).toEqual(mockEmails[0]);
expect(mockRepo.findOneByOrFail).toHaveBeenCalledWith({ Id: 1 });
});
});
@@ -0,0 +1,149 @@
import { Controller, Get, Post, Param, Query, Res, Logger, NotFoundException, Patch, Body } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { Response } from 'express';
import { Email } from '../database/entities/email.entity';
import { Attachment } from '../database/entities/attachment.entity';
import { Content } from '../database/entities/content.entity';
import { PaperlessService } from '../paperless/paperless.service';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
@Controller('api/emails')
export class EmailController {
private readonly logger = new Logger(EmailController.name);
constructor(
@InjectRepository(Email) private readonly emailRepo: Repository<Email>,
@InjectRepository(Attachment) private readonly attachmentRepo: Repository<Attachment>,
@InjectRepository(Content) private readonly contentRepo: Repository<Content>,
private readonly paperlessService: PaperlessService,
) {}
@Get()
async getEmails(
@Query('status') status?: string,
@Query('limit') limit?: string,
) {
const qb = this.emailRepo.createQueryBuilder('e')
.leftJoinAndSelect('e.Attachments', 'a')
.orderBy('e.Date', 'DESC')
.take(parseInt(limit ?? '50', 10));
if (status !== undefined) {
qb.where('e.Status = :status', { status: parseInt(status, 10) });
}
return qb.getMany();
}
@Get(':id')
async getEmail(@Param('id') id: string) {
return this.emailRepo.findOneOrFail({
where: { Id: parseInt(id, 10) },
relations: ['Attachments'],
});
}
@Get(':id/attachments')
async getAttachments(@Param('id') id: string) {
return this.attachmentRepo.find({
where: { EmailMessageId: parseInt(id, 10) },
order: { Id: 'ASC' },
});
}
@Get('attachments/:attachmentId/content')
async getAttachmentContent(
@Param('attachmentId') attachmentId: string,
@Res() res: Response,
) {
const id = parseInt(attachmentId, 10);
const attachment = await this.attachmentRepo.findOne({ where: { Id: id } });
if (!attachment) throw new NotFoundException('Anhang nicht gefunden');
const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: id } });
if (!content) throw new NotFoundException('Inhalt nicht gefunden');
res.setHeader('Content-Type', attachment.ContentType || 'application/octet-stream');
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(attachment.FileName)}"`);
res.send(content.Content1);
}
@Patch(':id/status')
@RequirePermissions(Permission.MANAGE_ALL)
async updateStatus(
@Param('id') id: string,
@Body('status') status: number,
) {
const email = await this.emailRepo.findOneOrFail({ where: { Id: parseInt(id, 10) } });
email.Status = status;
await this.emailRepo.save(email);
this.logger.log(`E-Mail ${id} auf Status ${status} gesetzt.`);
return { message: 'Status aktualisiert' };
}
@Post('check-attachments')
@RequirePermissions(Permission.MANAGE_ALL)
async checkAttachments() {
this.logger.log('Starte manuelle Prüfung der E-Mail-Anhänge in Paperless...');
try {
// Hole alle neuen E-Mails (Status = 0) inkl. Anhängen
const emails = await this.emailRepo.find({
where: { Status: 0 },
relations: ['Attachments'],
});
this.logger.log(`Gefunden: ${emails.length} E-Mails mit Status "Neu" (0). Beginne Prüfung...`);
let updatedCount = 0;
let skippedCount = 0;
for (const [index, email] of emails.entries()) {
if (!email.Attachments || email.Attachments.length === 0) {
skippedCount++;
continue;
}
let hasMatch = false;
for (const attachment of email.Attachments) {
// Prüfe nur PDFs und wenn eine Checksumme vorhanden ist
if (attachment.ContentType === 'application/pdf' && attachment.Checksum) {
this.logger.debug(`Prüfe Checksumme für E-Mail ${email.Id}, Anhang ${attachment.Id} (${attachment.FileName})`);
try {
const exists = await this.paperlessService.checksumExists(attachment.Checksum);
if (exists) {
this.logger.log(`Treffer! Anhang ${attachment.Id} (E-Mail ${email.Id}) in Paperless gefunden.`);
hasMatch = true;
break; // Ein Treffer reicht für diese E-Mail
}
} catch (err: any) {
this.logger.error(`Fehler bei Checksummen-Prüfung für Attachment ${attachment.Id}: ${err.message}`, err.stack);
}
}
}
// Wenn mindestens ein Anhang in Paperless existiert, markiere die Mail als verarbeitet (Status = 1)
if (hasMatch) {
email.Status = 1;
await this.emailRepo.save(email);
updatedCount++;
this.logger.log(`E-Mail ${email.Id} auf Status 1 (Verarbeitet) gesetzt.`);
}
// Zwischenstand loggen
if ((index + 1) % 10 === 0) {
this.logger.log(`Zwischenstand: ${index + 1} von ${emails.length} E-Mails geprüft.`);
}
}
this.logger.log(`Prüfung abgeschlossen. ${updatedCount} aktualisiert, ${skippedCount} ohne (PDF-)Anhänge übersprungen.`);
return { updatedCount };
} catch (error: any) {
this.logger.error(`Kritischer Fehler bei checkAttachments: ${error.message}`, error.stack);
throw error;
}
}
}
@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Email } from '../database/entities/email.entity';
import { Attachment } from '../database/entities/attachment.entity';
import { Content } from '../database/entities/content.entity';
import { EmailController } from './email.controller';
import { PaperlessModule } from '../paperless/paperless.module';
import { EmailPageCacheService } from './email-page-cache.service';
import { EmailImportController } from './email-import.controller';
import { EmailImportService } from './email-import.service';
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
import { Task } from '../database/entities/task.entity';
import { PreprocessingModule } from '../preprocessing/preprocessing.module';
@Module({
imports: [
TypeOrmModule.forFeature([Email, Attachment, Content, CorrespondentEmailMapping, Task]),
PaperlessModule,
PreprocessingModule,
],
controllers: [EmailController, EmailImportController],
providers: [EmailImportService, EmailPageCacheService],
exports: [EmailPageCacheService],
})
export class EmailModule {}