diff --git a/paperless-backend/src/email/email-import.controller.ts b/paperless-backend/src/email/email-import.controller.ts index 7555c60..9da16ae 100644 --- a/paperless-backend/src/email/email-import.controller.ts +++ b/paperless-backend/src/email/email-import.controller.ts @@ -95,15 +95,24 @@ export class EmailImportController { return { success: true }; } - // --- Split Checksum Check --- + // --- Duplikatprüfung --- + @Post('attachments/:attachmentId/check-duplicate') + @RequirePermissions(Permission.VIEW_MAIL) + async checkDuplicate(@Param('attachmentId') attachmentId: string) { + const isDuplicate = await this.importService.checkDuplicate( + parseInt(attachmentId, 10), + ); + return { isDuplicate }; + } + @Post('attachments/:attachmentId/check-split-checksum') @RequirePermissions(Permission.VIEW_MAIL) async checkSplitChecksum( - @Param('attachmentId') attachmentId: number, + @Param('attachmentId') attachmentId: string, @Body() body: { pages: { start: number; end: number } }, ) { - const isDuplicate = await this.importService.checkSplitChecksum( - attachmentId, + const isDuplicate = await this.importService.checkDuplicate( + parseInt(attachmentId, 10), body.pages, ); return { isDuplicate }; @@ -187,6 +196,8 @@ export class EmailImportController { return result; } catch (err: any) { this.logger.error(`Error executing import: ${err.message}`); + // Gezielte Fehler (z.B. 409 bei Duplikaten) unverändert weiterreichen + if (err instanceof HttpException) throw err; throw new HttpException(err.message, HttpStatus.INTERNAL_SERVER_ERROR); } } diff --git a/paperless-backend/src/email/email-import.service.spec.ts b/paperless-backend/src/email/email-import.service.spec.ts new file mode 100644 index 0000000..8474cbf --- /dev/null +++ b/paperless-backend/src/email/email-import.service.spec.ts @@ -0,0 +1,234 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConflictException } from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { PDFDocument } from 'pdf-lib'; +import { EmailImportService } from './email-import.service'; +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 { PdfService } from '../preprocessing/pdf.service'; +import { EmailPageCacheService } from './email-page-cache.service'; +import { ImapFolderService } from './imap-folder.service'; + +async function pdfMitSeiten(anzahl: number): Promise { + const doc = await PDFDocument.create(); + for (let i = 0; i < anzahl; i++) doc.addPage(); + return Buffer.from(await doc.save()); +} + +describe('EmailImportService – Duplikatprüfung', () => { + let service: EmailImportService; + let taskQuery: { + innerJoin: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + getCount: jest.Mock; + }; + let attachmentRepo: { findOne: jest.Mock }; + let contentRepo: { findOne: jest.Mock }; + let paperlessService: { + checksumExists: jest.Mock; + uploadDocument: jest.Mock; + }; + + beforeEach(async () => { + taskQuery = { + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(0), + }; + attachmentRepo = { + findOne: jest.fn().mockResolvedValue({ + Id: 7, + Checksum: 'abc123', + FileName: 'rechnung.pdf', + }), + }; + contentRepo = { findOne: jest.fn() }; + paperlessService = { + checksumExists: jest.fn().mockResolvedValue(false), + uploadDocument: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailImportService, + { provide: ConfigService, useValue: { get: jest.fn() } }, + { provide: getRepositoryToken(Email), useValue: {} }, + { provide: getRepositoryToken(Attachment), useValue: attachmentRepo }, + { provide: getRepositoryToken(Content), useValue: contentRepo }, + { + provide: getRepositoryToken(CorrespondentEmailMapping), + useValue: {}, + }, + { + provide: getRepositoryToken(Task), + useValue: { + createQueryBuilder: jest.fn().mockReturnValue(taskQuery), + }, + }, + { provide: PaperlessService, useValue: paperlessService }, + { provide: PdfService, useValue: {} }, + { provide: EmailPageCacheService, useValue: {} }, + { provide: ImapFolderService, useValue: {} }, + ], + }).compile(); + + service = module.get(EmailImportService); + }); + + describe('ganzer Anhang', () => { + it('erkennt ein Duplikat, dessen Import noch in Paperless verarbeitet wird', async () => { + taskQuery.getCount.mockResolvedValue(1); + + await expect(service.checkDuplicate(7)).resolves.toBe(true); + + expect(taskQuery.where).toHaveBeenCalledWith('a.Checksum = :checksum', { + checksum: 'abc123', + }); + expect(taskQuery.andWhere).toHaveBeenCalledWith( + 't.SourceAttachmentRange = :range', + { range: 'full' }, + ); + expect(paperlessService.checksumExists).not.toHaveBeenCalled(); + }); + + it('erkennt ein Duplikat, das bereits in Paperless liegt', async () => { + paperlessService.checksumExists.mockResolvedValue(true); + + await expect(service.checkDuplicate(7)).resolves.toBe(true); + expect(paperlessService.checksumExists).toHaveBeenCalledWith('abc123'); + }); + + it('meldet kein Duplikat, wenn weder Import läuft noch Paperless es kennt', async () => { + await expect(service.checkDuplicate(7)).resolves.toBe(false); + }); + + it('meldet kein Duplikat für Anhänge ohne Checksumme', async () => { + attachmentRepo.findOne.mockResolvedValue({ Id: 7, Checksum: '' }); + + await expect(service.checkDuplicate(7)).resolves.toBe(false); + expect(taskQuery.getCount).not.toHaveBeenCalled(); + expect(paperlessService.checksumExists).not.toHaveBeenCalled(); + }); + }); + + describe('Teilbereich (Split)', () => { + beforeEach(async () => { + contentRepo.findOne.mockResolvedValue({ + Content1: await pdfMitSeiten(3), + }); + }); + + it('sucht laufende Importe mit demselben Seitenbereich wie beim Upload', async () => { + taskQuery.getCount.mockResolvedValue(1); + + // 999 steht im Assistenten für "bis zur letzten Seite" + await expect( + service.checkDuplicate(7, { start: 2, end: 999 }), + ).resolves.toBe(true); + + expect(taskQuery.where).toHaveBeenCalledWith('a.Checksum = :checksum', { + checksum: 'abc123', + }); + expect(taskQuery.andWhere).toHaveBeenCalledWith( + 't.SourceAttachmentRange = :range', + { range: '2-3' }, + ); + }); + + it('prüft in Paperless die Checksumme des Teil-PDFs', async () => { + await expect( + service.checkDuplicate(7, { start: 1, end: 1 }), + ).resolves.toBe(false); + + const [checksum] = paperlessService.checksumExists.mock + .calls[0] as string[]; + expect(checksum).toMatch(/^[0-9a-f]{32}$/); + expect(checksum).not.toBe('abc123'); + }); + }); + + describe('executeImport', () => { + const importDaten = (splitRanges?: { start: number; end: number }[]) => ({ + emailDate: '2026-09-25', + attachments: [ + { + attachmentId: 7, + type: 'MAIN' as const, + splitRanges, + belegnummer: '2026-000123', + }, + ], + }); + + it('bricht vor Upload und Belegnummer ab, wenn das Dokument schon vorhanden ist', async () => { + paperlessService.checksumExists.mockResolvedValue(true); + + await expect(service.executeImport(importDaten())).rejects.toThrow( + ConflictException, + ); + expect(paperlessService.uploadDocument).not.toHaveBeenCalled(); + }); + + it('prüft bei geteilten Anhängen jeden Seitenbereich', async () => { + const check = jest + .spyOn(service, 'checkDuplicate') + .mockResolvedValue(true); + + await expect( + service.executeImport(importDaten([{ start: 1, end: 2 }])), + ).rejects.toThrow(ConflictException); + expect(check).toHaveBeenCalledWith(7, { start: 1, end: 2 }); + }); + + it('ignorierte Anhänge werden nicht geprüft', async () => { + const check = jest.spyOn(service, 'checkDuplicate'); + jest + .spyOn( + service as never as { runImport: () => Promise }, + 'runImport', + ) + .mockResolvedValue({}); + const daten = importDaten(); + daten.attachments[0] = { + ...daten.attachments[0], + type: 'IGNORE' as never, + }; + + await service.executeImport(daten); + expect(check).not.toHaveBeenCalled(); + }); + + it('lässt denselben Anhang nicht zweimal gleichzeitig importieren', async () => { + let ersterFertig!: () => void; + const run = jest + .spyOn( + service as never as { runImport: () => Promise }, + 'runImport', + ) + .mockImplementationOnce( + () => new Promise((resolve) => (ersterFertig = () => resolve({}))), + ) + .mockResolvedValue({}); + + const erster = service.executeImport(importDaten()); + await new Promise((r) => setImmediate(r)); + + await expect(service.executeImport(importDaten())).rejects.toThrow( + ConflictException, + ); + + ersterFertig(); + await erster; + // Nach Abschluss ist die Sperre wieder aufgehoben + await service.executeImport(importDaten()); + expect(run).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/paperless-backend/src/email/email-import.service.ts b/paperless-backend/src/email/email-import.service.ts index 76ab484..c1cc0ef 100644 --- a/paperless-backend/src/email/email-import.service.ts +++ b/paperless-backend/src/email/email-import.service.ts @@ -1,4 +1,10 @@ -import { Injectable, Logger, HttpException, HttpStatus } from '@nestjs/common'; +import { + Injectable, + Logger, + HttpException, + HttpStatus, + ConflictException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -31,6 +37,8 @@ function sanitizeFilename(name: string): string { @Injectable() export class EmailImportService { private readonly logger = new Logger(EmailImportService.name); + // Anhänge (Checksumme + Seitenbereich), die gerade importiert werden + private readonly laufendeImporte = new Set(); private readonly importJobs = new Map< string, { message: string; done: boolean } @@ -237,11 +245,29 @@ export class EmailImportService { } } - // --- Checksum Check for Split Documents --- - async checkSplitChecksum( + // --- 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 }, + pages?: { start: number; end: number }, ): Promise { + const attachment = await this.attachmentRepo.findOne({ + where: { Id: attachmentId }, + }); + if (!attachment) return false; + + if (!pages) { + if (!attachment.Checksum) return false; + if (await this.hasPendingImport(attachment.Checksum, 'full')) return true; + return this.paperlessService.checksumExists(attachment.Checksum); + } + const content = await this.contentRepo.findOne({ where: { AttachmentEntityId: attachmentId }, }); @@ -251,13 +277,21 @@ export class EmailImportService { 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; + // 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; + } const sliced = await PDFDocument.create(); const indices = Array.from( - { length: endIdx - startIdx + 1 }, - (_, i) => startIdx + i, + { length: end - start + 1 }, + (_, i) => start - 1 + i, ); const copied = await sliced.copyPages(pdfDoc, indices); copied.forEach((p) => sliced.addPage(p)); @@ -269,6 +303,25 @@ export class EmailImportService { return this.paperlessService.checksumExists(checksum); } + /** + * Läuft für einen Anhang mit dieser Checksumme (egal aus welcher E-Mail) + * bereits ein Import desselben Seitenbereichs? Fehlgeschlagene Tasks löscht + * der Task-Processor, abgeschlossene findet Paperless selbst. + */ + private async hasPendingImport( + checksum: string, + range: string, + ): Promise { + const count = await this.taskRepo + .createQueryBuilder('t') + .innerJoin(Attachment, 'a', 'a.Id = t.SourceAttachmentID') + .where('a.Checksum = :checksum', { checksum }) + .andWhere('t.SourceAttachmentRange = :range', { range }) + .andWhere('(t.Fertig IS NULL OR t.Fertig = 0)') + .getCount(); + return count > 0; + } + // --- Print Preview --- async generatePrintPdf( attachmentId: number, @@ -460,6 +513,11 @@ export class EmailImportService { } // --- 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: { @@ -478,6 +536,86 @@ export class EmailImportService { belegnummer?: string; }[]; emailDate: string; + }): Promise<{ success: boolean; results: any[] }> { + const sperren = await this.sperreImporte(data.attachments); + try { + return await this.runImport(data); + } finally { + sperren.forEach((key) => this.laufendeImporte.delete(key)); + } + } + + /** + * Reserviert jeden zu importierenden Anhang (bzw. Seitenbereich) für diesen + * Import und wirft einen ConflictException, wenn er schon vorhanden ist oder + * parallel importiert wird. Die Reservierung schließt die Lücke zwischen + * Prüfung und Upload, in der Paperless noch nichts davon weiß. + */ + private async sperreImporte( + attachments: { + attachmentId: number; + type: string; + splitRanges?: { start: number; end: number }[]; + }[], + ): Promise { + const sperren: string[] = []; + try { + for (const att of attachments) { + if (att.type === 'IGNORE') continue; + const attachment = await this.attachmentRepo.findOne({ + where: { Id: att.attachmentId }, + }); + if (!attachment) continue; + + const ranges = att.splitRanges?.length ? att.splitRanges : [undefined]; + for (const range of ranges) { + const bereich = range ? `${range.start}-${range.end}` : 'full'; + const key = `${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; + } + } + + 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; + }[]; + emailDate: string; }): Promise<{ success: boolean; results: any[] }> { const tempDir = await fs.mkdtemp( path.join(os.tmpdir(), 'paperless-mail-import-'), @@ -500,7 +638,9 @@ export class EmailImportService { if (!content) continue; const originalPdfBytes = content.Content1; - const baseFilename = sanitizeFilename(attachmentEntity.FileName.replace(/\.pdf$/i, '')); + const baseFilename = sanitizeFilename( + attachmentEntity.FileName.replace(/\.pdf$/i, ''), + ); const paperlessIds: any = {}; const uploadPromises = []; diff --git a/paperless-frontend/src/api/email-import.ts b/paperless-frontend/src/api/email-import.ts index a486654..6659688 100644 --- a/paperless-frontend/src/api/email-import.ts +++ b/paperless-frontend/src/api/email-import.ts @@ -57,6 +57,12 @@ export const emailImportApi = { return res.data; }, + // Prüft auf Duplikate – in Paperless und bei Importen, die dort noch verarbeitet werden. + checkDuplicate: async (attachmentId: number): Promise => { + const res = await api.post<{ isDuplicate: boolean }>(`/api/email-import/attachments/${attachmentId}/check-duplicate`); + return res.data.isDuplicate; + }, + checkSplitChecksum: async (attachmentId: number, pages: { start: number; end: number }): Promise => { const res = await api.post<{ isDuplicate: boolean }>(`/api/email-import/attachments/${attachmentId}/check-split-checksum`, { pages }); return res.data.isDuplicate; diff --git a/paperless-frontend/src/components/MailImportWizard.tsx b/paperless-frontend/src/components/MailImportWizard.tsx index 10b4644..ca2685a 100644 --- a/paperless-frontend/src/components/MailImportWizard.tsx +++ b/paperless-frontend/src/components/MailImportWizard.tsx @@ -81,7 +81,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a // Load correspondents and try to find suggestion loadCorrespondents(); - // Check for duplicates in Paperless + // Duplikate prüfen (Paperless und noch laufende Importe) checkDuplicates(initialData); } }, [visible, attachments]); @@ -94,7 +94,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a const item = updatedData[i]; const attachment = attachments.find(a => a.Id === item.attachmentId); if (attachment && attachment.Checksum) { - const exists = await paperlessApi.checksumExists(attachment.Checksum); + const exists = await emailImportApi.checkDuplicate(attachment.Id); if (exists) { updatedData[i] = { ...updatedData[i], type: 'IGNORE', isDuplicate: true }; } @@ -187,6 +187,29 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a } }; + // Prüft alle noch zu importierenden Dokumente erneut auf Duplikate – seit dem + // Öffnen kann dasselbe Dokument aus einer anderen E-Mail importiert worden + // sein. Treffer werden als ignoriert markiert und zurückgegeben. + const recheckDuplicates = async (): Promise => { + const candidates = importData.filter(i => i.type !== 'IGNORE'); + const results = await Promise.all(candidates.map(item => + item.pages + ? emailImportApi.checkSplitChecksum(item.attachmentId, item.pages) + : emailImportApi.checkDuplicate(item.attachmentId) + )); + const duplicates = candidates.filter((_, idx) => results[idx]); + if (duplicates.length > 0) { + const ids = new Set(duplicates.map(d => d.virtualId)); + setImportData(prev => prev.map(item => + ids.has(item.virtualId) ? { ...item, type: 'IGNORE' as const, isDuplicate: true } : item + )); + message.warning( + `Bereits in Paperless vorhanden, wird ignoriert: ${duplicates.map(d => d.fileName).join(', ')}` + ); + } + return duplicates; + }; + const loadBelegnummern = async () => { // No longer fetching from API here, just initializing state for Step 2 for (const item of importData) { @@ -309,6 +332,21 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a if (currentStep === 0) { await loadBelegnummern(); } else if (currentStep === 1) { + // Vor dem Abrufen der Belegnummern erneut prüfen, damit für ein + // Duplikat gar nicht erst eine Nummer reserviert wird + setLoading(true); + try { + const duplicates = await recheckDuplicates(); + if (duplicates.length > 0) { + setCurrentStep(0); + return; + } + } catch { + message.error('Duplikatprüfung fehlgeschlagen – bitte erneut versuchen.'); + return; + } finally { + setLoading(false); + } await preFetchBelegnummern(); } setCurrentStep(currentStep + 1); @@ -316,6 +354,33 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a const executeImport = async () => { setLoading(true); + // Letzte Prüfung vor dem Import: Duplikate fliegen raus, ihre bereits + // reservierten Belegnummern werden wieder freigegeben + try { + const duplicates = await recheckDuplicates(); + if (duplicates.length > 0) { + for (const item of duplicates) { + const vid = item.virtualId; + const num = belegnummern[vid]; + if ((belegnummerMode[vid] || 'neu') === 'neu' && num && num !== '000000') { + const dateStr = (eingangsdaten[vid] || dayjs(email.Date)).format('YYYY-MM-DD'); + await emailImportApi.releaseBelegnummer(dateStr, num).catch(() => {}); + } + } + setLoading(false); + return; + } + } catch { + message.error('Duplikatprüfung fehlgeschlagen – bitte erneut versuchen.'); + setLoading(false); + return; + } + if (!importData.some(i => i.type !== 'IGNORE')) { + message.warning('Keine Dokumente zum Importieren ausgewählt.'); + setLoading(false); + return; + } + setImportStatus('Import wird gestartet...'); const jobId = crypto.randomUUID ? crypto.randomUUID() : `job-${Date.now()}`; const statusPoll = setInterval(async () => { @@ -371,7 +436,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a setImportSuccess(true); setCurrentStep(2); } catch (e: any) { - message.error(`Fehler beim Import: ${e.message}`); + message.error(`Fehler beim Import: ${e.response?.data?.message ?? e.message}`); } finally { clearInterval(statusPoll); setImportStatus('');