fix(mail): Duplikate auch bei neu erzeugten PDFs erkennen
Build and Push Multi-Platform Images / build-and-push (push) Successful in 45s

Absender erzeugen PDFs beim erneuten Versand oft neu (andere
Erstellungszeit/Dokument-ID), dadurch unterscheidet sich die Checksumme
und die Duplikatprüfung griff nicht (z.B. Mail 7071 vs. 7072).

- Neuer Text-Fingerprint (SHA-256 über normalisierten PDF-Text via
  pdf-parse) in Attachments.ContentFingerprint, inkl. Migration und Index
- Wird beim Mail-Abruf berechnet; Altbestand füllt ein Hintergrund-
  Backfill beim Start auf, fehlende Werte werden bei Bedarf nachberechnet
- Duplikatprüfung und Import-Sperre berücksichtigen Anhänge mit gleichem
  Inhalt, die importiert sind oder gerade importiert werden
- Jest braucht für pdf-parse --experimental-vm-modules (test-Skripte)

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 12:27:31 +02:00
1 parent 534b313054
commit 797cbb9b71
12 files changed
+746 -7

No files matched your search

@@ -0,0 +1,124 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, Repository } from 'typeorm';
import { Attachment } from '../database/entities/attachment.entity';
import { Content } from '../database/entities/content.entity';
import { computeContentFingerprint } from './pdf-fingerprint.util';
/**
* Verwaltet den inhaltsbasierten Fingerprint der Mail-Anhänge: berechnet ihn
* bei Bedarf nach, füllt ihn für Altbestände im Hintergrund auf und findet
* bereits importierte Anhänge mit gleichem Inhalt.
*/
@Injectable()
export class AttachmentFingerprintService implements OnApplicationBootstrap {
private readonly logger = new Logger(AttachmentFingerprintService.name);
constructor(
@InjectRepository(Attachment)
private readonly attachmentRepo: Repository<Attachment>,
@InjectRepository(Content)
private readonly contentRepo: Repository<Content>,
) {}
onApplicationBootstrap(): void {
// Nicht auf den Backfill warten – der Start soll nicht blockieren
this.backfill().catch((err: Error) =>
this.logger.error(`Fingerprint-Backfill fehlgeschlagen: ${err.message}`),
);
}
/**
* Liefert den Fingerprint eines Anhangs und berechnet ihn nach, falls er
* noch fehlt (z.B. Mail vor dem Backfill eingegangen). `null`, wenn kein
* vergleichbarer Text vorliegt.
*/
async ensure(attachment: Attachment): Promise<string | null> {
if (attachment.ContentFingerprint != null) {
return attachment.ContentFingerprint || null;
}
if (attachment.ContentType !== 'application/pdf') return null;
const content = await this.contentRepo.findOne({
where: { AttachmentEntityId: attachment.Id },
});
if (!content) return null;
const fingerprint = await computeContentFingerprint(content.Content1);
if (fingerprint === null) return null;
await this.attachmentRepo.update(attachment.Id, {
ContentFingerprint: fingerprint,
});
attachment.ContentFingerprint = fingerprint;
return fingerprint || null;
}
/**
* Gibt es einen anderen Anhang mit gleichem Inhalt, dessen Seitenbereich
* bereits importiert wurde oder gerade importiert wird?
*/
async hasImportedTwin(
attachmentId: number,
fingerprint: string,
range: string,
): Promise<boolean> {
const count = await this.attachmentRepo
.createQueryBuilder('a')
.where('a.ContentFingerprint = :fingerprint', { fingerprint })
.andWhere('a.Id != :attachmentId', { attachmentId })
.andWhere(
new Brackets((qb) => {
// Abgeschlossener Import mit ermittelter Paperless-ID
qb.where(
"JSON_CONTAINS_PATH(a.PaperlessDocumentIds, 'one', :jsonPath) = 1",
{ jsonPath: `$."${range}"` },
)
// Laufender oder abgeschlossener Upload (fehlgeschlagene Tasks
// löscht der Task-Processor)
.orWhere(
'EXISTS (SELECT 1 FROM tasks t WHERE t.SourceAttachmentID = a.Id AND t.SourceAttachmentRange = :range)',
{ range },
);
// Ganzer Anhang: jeder Import zählt, auch wenn die ID beim Upload
// nicht ermittelt werden konnte
if (range === 'full') qb.orWhere('a.ImportStatus = 1');
}),
)
.getCount();
return count > 0;
}
/** Berechnet fehlende Fingerprints aller PDF-Anhänge in kleinen Portionen. */
async backfill(batchSize = 25): Promise<number> {
let lastId = 0;
let updated = 0;
for (;;) {
const batch = await this.attachmentRepo
.createQueryBuilder('a')
.select(['a.Id', 'a.ContentType', 'a.ContentFingerprint'])
.where('a.ContentFingerprint IS NULL')
.andWhere("a.ContentType = 'application/pdf'")
.andWhere('a.Id > :lastId', { lastId })
.orderBy('a.Id', 'ASC')
.take(batchSize)
.getMany();
if (batch.length === 0) break;
for (const attachment of batch) {
// lastId weiterschieben, damit unlesbare PDFs nicht endlos drankommen
lastId = attachment.Id;
try {
if ((await this.ensure(attachment)) !== null) updated++;
} catch (err) {
this.logger.warn(
`Fingerprint für Anhang ${attachment.Id} nicht berechenbar: ${(err as Error).message}`,
);
}
}
}
if (updated > 0) {
this.logger.log(`Fingerprint-Backfill: ${updated} Anhänge ergänzt.`);
}
return updated;
}
}