fix(mail): Duplikate auch bei gerade laufenden Importen erkennen
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:
bjoernpoettkerandClaude Opus 5.5 committed 2026-09-25 10:29:43 +02:00
1 parent 3cea3fb239
commit 534b313054
5 files changed
+472 -16

No files matched your search

@@ -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 = [];