fix(mail): Duplikate auch bei gerade laufenden Importen erkennen
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
Wurde dieselbe Mail zweimal weitergeleitet, erkannte der Import-Assistent das Duplikat nicht, solange Paperless das erste Dokument noch verarbeitete – geprüft wurde nur die Checksumme in Paperless. - checkDuplicate() berücksichtigt zusätzlich offene Paperless-Tasks von Anhängen mit gleicher Checksumme (gleicher Seitenbereich) - Assistent prüft erneut vor dem Abrufen der Belegnummern und vor dem Import; Duplikate werden ignoriert, reservierte Nummern freigegeben - executeImport prüft serverseitig vor Upload und Festlegen der Nummer, sperrt laufende Importe gegen parallele Doppelimporte und meldet 409 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3cea3fb239
commit
534b313054
5 files changed
+472
-16
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Buffer> {
|
||||
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<unknown> },
|
||||
'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<unknown> },
|
||||
'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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
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<boolean> {
|
||||
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<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;
|
||||
}
|
||||
|
||||
// --- 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<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';
|
||||
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 = [];
|
||||
|
||||
Reference in new issue
Block a user