fix(mail): Duplikate auch bei neu erzeugten PDFs erkennen
Build and Push Multi-Platform Images / build-and-push (push) Successful in 45s
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:
1 parent
534b313054
commit
797cbb9b71
12 files changed
+746
-7
No files matched your search
@@ -31,6 +31,15 @@ export class Attachment {
|
||||
@Column({ type: 'varchar', length: 32, default: '' })
|
||||
Checksum!: string;
|
||||
|
||||
/**
|
||||
* SHA-256 über den normalisierten PDF-Text (siehe pdf-fingerprint.util).
|
||||
* Erkennt Duplikate, deren Datei neu erzeugt wurde (andere Checksumme).
|
||||
* `''` = berechnet, aber ohne Textebene; `null` = noch nicht berechnet.
|
||||
*/
|
||||
@Index('IX_Attachments_ContentFingerprint')
|
||||
@Column({ type: 'varchar', length: 64, nullable: true })
|
||||
ContentFingerprint!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
ContentId!: string | null;
|
||||
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Inhaltsbasierter Fingerprint für Mail-Anhänge: erkennt Duplikate, deren
|
||||
* PDF vom Absender neu erzeugt wurde und daher eine andere Checksumme hat.
|
||||
* Bestehende Anhänge werden beim Start im Hintergrund nachberechnet
|
||||
* (AttachmentFingerprintService).
|
||||
*/
|
||||
export class AttachmentContentFingerprint1790000000000 implements MigrationInterface {
|
||||
name = 'AttachmentContentFingerprint1790000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE \`Attachments\`
|
||||
ADD \`ContentFingerprint\` varchar(64) NULL DEFAULT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX \`IX_Attachments_ContentFingerprint\`
|
||||
ON \`Attachments\` (\`ContentFingerprint\`)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IX_Attachments_ContentFingerprint\` ON \`Attachments\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`Attachments\` DROP COLUMN \`ContentFingerprint\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { Email } from '../database/entities/email.entity';
|
||||
import { Attachment } from '../database/entities/attachment.entity';
|
||||
import { Content } from '../database/entities/content.entity';
|
||||
import { isERechnung } from './zugferd.util';
|
||||
import { computeContentFingerprint } from '../email/pdf-fingerprint.util';
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
@@ -304,6 +305,10 @@ export class EmailDownloadService {
|
||||
attachment.IsEmbedded = isEmbedded;
|
||||
attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null;
|
||||
attachment.Checksum = crypto.createHash('md5').update(buffer).digest('hex');
|
||||
attachment.ContentFingerprint =
|
||||
contentType.toLowerCase() === 'application/pdf'
|
||||
? await computeContentFingerprint(buffer)
|
||||
: null;
|
||||
attachment.Erechnung =
|
||||
contentType.toLowerCase() === 'application/pdf'
|
||||
? isERechnung(buffer)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
|
||||
import { Attachment } from '../database/entities/attachment.entity';
|
||||
import { Content } from '../database/entities/content.entity';
|
||||
|
||||
async function pdfMitText(text: string): Promise<Buffer> {
|
||||
const doc = await PDFDocument.create();
|
||||
const font = await doc.embedFont(StandardFonts.Helvetica);
|
||||
doc.addPage().drawText(text, { x: 50, y: 700, font, size: 12 });
|
||||
return Buffer.from(await doc.save());
|
||||
}
|
||||
|
||||
const pdfAnhang = (felder: Partial<Attachment> = {}) =>
|
||||
({
|
||||
Id: 7,
|
||||
ContentType: 'application/pdf',
|
||||
ContentFingerprint: null,
|
||||
...felder,
|
||||
}) as Attachment;
|
||||
|
||||
describe('AttachmentFingerprintService', () => {
|
||||
let service: AttachmentFingerprintService;
|
||||
let query: Record<string, jest.Mock>;
|
||||
let attachmentRepo: { update: jest.Mock; createQueryBuilder: jest.Mock };
|
||||
let contentRepo: { findOne: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
query = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
getCount: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
attachmentRepo = {
|
||||
update: jest.fn(),
|
||||
createQueryBuilder: jest.fn().mockReturnValue(query),
|
||||
};
|
||||
contentRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
Content1: await pdfMitText('Lastschriftankuendigung Mandat 0000844118'),
|
||||
}),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
AttachmentFingerprintService,
|
||||
{ provide: getRepositoryToken(Attachment), useValue: attachmentRepo },
|
||||
{ provide: getRepositoryToken(Content), useValue: contentRepo },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(AttachmentFingerprintService);
|
||||
});
|
||||
|
||||
describe('ensure', () => {
|
||||
it('berechnet einen fehlenden Fingerprint und speichert ihn', async () => {
|
||||
const anhang = pdfAnhang();
|
||||
|
||||
const fp = await service.ensure(anhang);
|
||||
|
||||
expect(fp).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(attachmentRepo.update).toHaveBeenCalledWith(7, {
|
||||
ContentFingerprint: fp,
|
||||
});
|
||||
expect(anhang.ContentFingerprint).toBe(fp);
|
||||
});
|
||||
|
||||
it('nutzt einen vorhandenen Fingerprint ohne erneute Berechnung', async () => {
|
||||
await expect(
|
||||
service.ensure(pdfAnhang({ ContentFingerprint: 'abc' })),
|
||||
).resolves.toBe('abc');
|
||||
expect(contentRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('liefert null für PDFs ohne Textebene', async () => {
|
||||
await expect(
|
||||
service.ensure(pdfAnhang({ ContentFingerprint: '' })),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('ignoriert Anhänge, die kein PDF sind', async () => {
|
||||
await expect(
|
||||
service.ensure(pdfAnhang({ ContentType: 'message/rfc822' })),
|
||||
).resolves.toBeNull();
|
||||
expect(contentRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasImportedTwin', () => {
|
||||
const bedingungen = (range: string) => {
|
||||
const brackets = query.andWhere.mock.calls
|
||||
.map(([arg]) => arg as unknown)
|
||||
.find((arg): arg is Brackets => arg instanceof Brackets)!;
|
||||
const inner = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orWhere: jest.fn().mockReturnThis(),
|
||||
};
|
||||
brackets.whereFactory(inner as never);
|
||||
return { inner, range };
|
||||
};
|
||||
|
||||
it('sucht andere Anhänge mit gleichem Fingerprint', async () => {
|
||||
query.getCount.mockResolvedValue(1);
|
||||
|
||||
await expect(service.hasImportedTwin(7, 'fp', 'full')).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(query.where).toHaveBeenCalledWith(
|
||||
'a.ContentFingerprint = :fingerprint',
|
||||
{ fingerprint: 'fp' },
|
||||
);
|
||||
expect(query.andWhere).toHaveBeenCalledWith('a.Id != :attachmentId', {
|
||||
attachmentId: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('zählt beim ganzen Anhang jeden abgeschlossenen Import', async () => {
|
||||
await service.hasImportedTwin(7, 'fp', 'full');
|
||||
const { inner } = bedingungen('full');
|
||||
|
||||
expect(inner.where).toHaveBeenCalledWith(expect.any(String), {
|
||||
jsonPath: '$."full"',
|
||||
});
|
||||
expect(inner.orWhere).toHaveBeenCalledWith('a.ImportStatus = 1');
|
||||
});
|
||||
|
||||
it('verlangt bei Teilbereichen denselben Seitenbereich', async () => {
|
||||
await service.hasImportedTwin(7, 'fp', '2-3');
|
||||
const { inner } = bedingungen('2-3');
|
||||
|
||||
expect(inner.where).toHaveBeenCalledWith(expect.any(String), {
|
||||
jsonPath: '$."2-3"',
|
||||
});
|
||||
expect(inner.orWhere).toHaveBeenCalledWith(expect.any(String), {
|
||||
range: '2-3',
|
||||
});
|
||||
expect(inner.orWhere).not.toHaveBeenCalledWith('a.ImportStatus = 1');
|
||||
});
|
||||
});
|
||||
|
||||
it('backfill berechnet fehlende Fingerprints portionsweise', async () => {
|
||||
query.getMany
|
||||
.mockResolvedValueOnce([pdfAnhang({ Id: 1 }), pdfAnhang({ Id: 2 })])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.backfill(2)).resolves.toBe(2);
|
||||
expect(attachmentRepo.update).toHaveBeenCalledTimes(2);
|
||||
expect(query.andWhere).toHaveBeenLastCalledWith('a.Id > :lastId', {
|
||||
lastId: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
|
||||
|
||||
async function pdfMitSeiten(anzahl: number): Promise<Buffer> {
|
||||
const doc = await PDFDocument.create();
|
||||
@@ -34,6 +35,7 @@ describe('EmailImportService – Duplikatprüfung', () => {
|
||||
checksumExists: jest.Mock;
|
||||
uploadDocument: jest.Mock;
|
||||
};
|
||||
let fingerprints: { ensure: jest.Mock; hasImportedTwin: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
taskQuery = {
|
||||
@@ -54,6 +56,10 @@ describe('EmailImportService – Duplikatprüfung', () => {
|
||||
checksumExists: jest.fn().mockResolvedValue(false),
|
||||
uploadDocument: jest.fn(),
|
||||
};
|
||||
fingerprints = {
|
||||
ensure: jest.fn().mockResolvedValue(null),
|
||||
hasImportedTwin: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -76,6 +82,7 @@ describe('EmailImportService – Duplikatprüfung', () => {
|
||||
{ provide: PdfService, useValue: {} },
|
||||
{ provide: EmailPageCacheService, useValue: {} },
|
||||
{ provide: ImapFolderService, useValue: {} },
|
||||
{ provide: AttachmentFingerprintService, useValue: fingerprints },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -109,6 +116,20 @@ describe('EmailImportService – Duplikatprüfung', () => {
|
||||
await expect(service.checkDuplicate(7)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('erkennt neu erzeugte PDFs mit gleichem Inhalt über den Fingerprint', async () => {
|
||||
fingerprints.ensure.mockResolvedValue('fp-inhalt');
|
||||
fingerprints.hasImportedTwin.mockResolvedValue(true);
|
||||
|
||||
await expect(service.checkDuplicate(7)).resolves.toBe(true);
|
||||
|
||||
expect(fingerprints.hasImportedTwin).toHaveBeenCalledWith(
|
||||
7,
|
||||
'fp-inhalt',
|
||||
'full',
|
||||
);
|
||||
expect(paperlessService.checksumExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('meldet kein Duplikat für Anhänge ohne Checksumme', async () => {
|
||||
attachmentRepo.findOne.mockResolvedValue({ Id: 7, Checksum: '' });
|
||||
|
||||
@@ -142,6 +163,20 @@ describe('EmailImportService – Duplikatprüfung', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('vergleicht den Fingerprint mit demselben Seitenbereich', async () => {
|
||||
fingerprints.ensure.mockResolvedValue('fp-inhalt');
|
||||
fingerprints.hasImportedTwin.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
service.checkDuplicate(7, { start: 2, end: 999 }),
|
||||
).resolves.toBe(true);
|
||||
expect(fingerprints.hasImportedTwin).toHaveBeenCalledWith(
|
||||
7,
|
||||
'fp-inhalt',
|
||||
'2-3',
|
||||
);
|
||||
});
|
||||
|
||||
it('prüft in Paperless die Checksumme des Teil-PDFs', async () => {
|
||||
await expect(
|
||||
service.checkDuplicate(7, { start: 1, end: 1 }),
|
||||
@@ -205,6 +240,39 @@ describe('EmailImportService – Duplikatprüfung', () => {
|
||||
expect(check).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sperrt auch parallele Importe verschiedener Dateien mit gleichem Inhalt', async () => {
|
||||
fingerprints.ensure.mockResolvedValue('fp-inhalt');
|
||||
attachmentRepo.findOne.mockImplementation(
|
||||
({ where: { Id } }: { where: { Id: number } }) =>
|
||||
Promise.resolve({
|
||||
Id,
|
||||
Checksum: `checksum-${Id}`,
|
||||
FileName: 'a.pdf',
|
||||
}),
|
||||
);
|
||||
let ersterFertig!: () => void;
|
||||
jest
|
||||
.spyOn(
|
||||
service as never as { runImport: () => Promise<unknown> },
|
||||
'runImport',
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (ersterFertig = () => resolve({}))),
|
||||
);
|
||||
|
||||
const erster = service.executeImport(importDaten());
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
const zweiter = importDaten();
|
||||
zweiter.attachments[0] = { ...zweiter.attachments[0], attachmentId: 8 };
|
||||
await expect(service.executeImport(zweiter)).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
|
||||
ersterFertig();
|
||||
await erster;
|
||||
});
|
||||
|
||||
it('lässt denselben Anhang nicht zweimal gleichzeitig importieren', async () => {
|
||||
let ersterFertig!: () => void;
|
||||
const run = jest
|
||||
|
||||
@@ -20,6 +20,7 @@ import { deriveTaskMetadata } from '../paperless/task-metadata.util';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { EmailPageCacheService } from './email-page-cache.service';
|
||||
import { ImapFolderService } from './imap-folder.service';
|
||||
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
|
||||
import { PdfService } from '../preprocessing/pdf.service';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -71,6 +72,7 @@ export class EmailImportService {
|
||||
private readonly pdfService: PdfService,
|
||||
private readonly pageCache: EmailPageCacheService,
|
||||
private readonly imapFolderService: ImapFolderService,
|
||||
private readonly fingerprints: AttachmentFingerprintService,
|
||||
) {}
|
||||
|
||||
async ensurePreviews(emailId: number): Promise<void> {
|
||||
@@ -263,8 +265,14 @@ export class EmailImportService {
|
||||
if (!attachment) return false;
|
||||
|
||||
if (!pages) {
|
||||
if (
|
||||
attachment.Checksum &&
|
||||
(await this.hasPendingImport(attachment.Checksum, 'full'))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (await this.hasTwinByContent(attachment, 'full')) return true;
|
||||
if (!attachment.Checksum) return false;
|
||||
if (await this.hasPendingImport(attachment.Checksum, 'full')) return true;
|
||||
return this.paperlessService.checksumExists(attachment.Checksum);
|
||||
}
|
||||
|
||||
@@ -287,6 +295,9 @@ export class EmailImportService {
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (await this.hasTwinByContent(attachment, `${start}-${end}`)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sliced = await PDFDocument.create();
|
||||
const indices = Array.from(
|
||||
@@ -322,6 +333,19 @@ export class EmailImportService {
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gleicher Inhalt, andere Datei: Absender erzeugen PDFs beim erneuten
|
||||
* Versand oft neu (andere Checksumme). Der Text-Fingerprint erkennt das.
|
||||
*/
|
||||
private async hasTwinByContent(
|
||||
attachment: Attachment,
|
||||
range: string,
|
||||
): Promise<boolean> {
|
||||
const fingerprint = await this.fingerprints.ensure(attachment);
|
||||
if (!fingerprint) return false;
|
||||
return this.fingerprints.hasImportedTwin(attachment.Id, fingerprint, range);
|
||||
}
|
||||
|
||||
// --- Print Preview ---
|
||||
async generatePrintPdf(
|
||||
attachmentId: number,
|
||||
@@ -570,7 +594,9 @@ export class EmailImportService {
|
||||
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}`;
|
||||
// Fingerprint vor Checksumme: fängt auch neu erzeugte PDFs ab
|
||||
const fingerprint = await this.fingerprints.ensure(attachment);
|
||||
const key = `${fingerprint || attachment.Checksum || `id:${attachment.Id}`}:${bereich}`;
|
||||
const bezeichnung = range
|
||||
? `„${attachment.FileName}" (Seiten ${bereich})`
|
||||
: `„${attachment.FileName}"`;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { EmailPageCacheService } from './email-page-cache.service';
|
||||
import { EmailImportController } from './email-import.controller';
|
||||
import { EmailImportService } from './email-import.service';
|
||||
import { ImapFolderService } from './imap-folder.service';
|
||||
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
|
||||
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
|
||||
import { Task } from '../database/entities/task.entity';
|
||||
import { PreprocessingModule } from '../preprocessing/preprocessing.module';
|
||||
@@ -27,7 +28,12 @@ import { PreprocessingModule } from '../preprocessing/preprocessing.module';
|
||||
PreprocessingModule,
|
||||
],
|
||||
controllers: [EmailController, EmailImportController],
|
||||
providers: [EmailImportService, EmailPageCacheService, ImapFolderService],
|
||||
providers: [
|
||||
EmailImportService,
|
||||
EmailPageCacheService,
|
||||
ImapFolderService,
|
||||
AttachmentFingerprintService,
|
||||
],
|
||||
exports: [EmailPageCacheService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||
import { computeContentFingerprint } from './pdf-fingerprint.util';
|
||||
|
||||
async function pdfMitText(
|
||||
zeilen: string[],
|
||||
erstellt = new Date('2026-09-24T12:00:00Z'),
|
||||
): Promise<Buffer> {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.setCreationDate(erstellt);
|
||||
doc.setModificationDate(erstellt);
|
||||
const font = await doc.embedFont(StandardFonts.Helvetica);
|
||||
const page = doc.addPage();
|
||||
zeilen.forEach((text, i) =>
|
||||
page.drawText(text, { x: 50, y: 700 - i * 20, font, size: 12 }),
|
||||
);
|
||||
return Buffer.from(await doc.save());
|
||||
}
|
||||
|
||||
const TEXT = [
|
||||
'Lastschriftankuendigung / Pre-Notification',
|
||||
'Mandatsreferenz 0000844118',
|
||||
'Betrag 12,50 EUR',
|
||||
];
|
||||
|
||||
describe('computeContentFingerprint', () => {
|
||||
it('liefert für neu erzeugte PDFs mit gleichem Inhalt denselben Fingerprint', async () => {
|
||||
const a = await pdfMitText(TEXT, new Date('2026-09-24T12:37:00Z'));
|
||||
const b = await pdfMitText(TEXT, new Date('2026-09-24T13:45:00Z'));
|
||||
expect(a.equals(b)).toBe(false);
|
||||
|
||||
const fpA = await computeContentFingerprint(a);
|
||||
expect(fpA).toMatch(/^[0-9a-f]{64}$/);
|
||||
await expect(computeContentFingerprint(b)).resolves.toBe(fpA);
|
||||
});
|
||||
|
||||
it('unterscheidet PDFs mit anderem Inhalt', async () => {
|
||||
const a = await pdfMitText(TEXT);
|
||||
const b = await pdfMitText([...TEXT.slice(0, 2), 'Betrag 13,50 EUR']);
|
||||
|
||||
expect(await computeContentFingerprint(a)).not.toBe(
|
||||
await computeContentFingerprint(b),
|
||||
);
|
||||
});
|
||||
|
||||
it('liefert einen Leerstring für PDFs ohne Textebene (z.B. Scans)', async () => {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.addPage();
|
||||
const leer = Buffer.from(await doc.save());
|
||||
|
||||
await expect(computeContentFingerprint(leer)).resolves.toBe('');
|
||||
});
|
||||
|
||||
it('liefert null, wenn die Datei kein lesbares PDF ist', async () => {
|
||||
await expect(
|
||||
computeContentFingerprint(Buffer.from('kein pdf')),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
|
||||
/**
|
||||
* Unterhalb dieser Textlänge gilt ein PDF als "ohne Textebene" (z.B. Scan) –
|
||||
* ein paar Zeichen reichen nicht, um Dokumente sicher zu unterscheiden.
|
||||
*/
|
||||
const MIN_TEXT_LENGTH = 20;
|
||||
|
||||
/**
|
||||
* Inhaltsbasierter Fingerprint eines PDFs: SHA-256 über den extrahierten,
|
||||
* whitespace-normalisierten Text. Anders als die Datei-Checksumme bleibt er
|
||||
* gleich, wenn ein Absender dasselbe Dokument neu erzeugt (andere
|
||||
* Erstellungszeit, Dokument-ID o.ä.).
|
||||
*
|
||||
* @returns Fingerprint (64 Hex-Zeichen), `''` bei PDFs ohne verwertbaren Text
|
||||
* (bereits berechnet, aber nicht vergleichbar) oder `null`, wenn das
|
||||
* PDF nicht gelesen werden konnte.
|
||||
*/
|
||||
export async function computeContentFingerprint(
|
||||
pdf: Buffer,
|
||||
): Promise<string | null> {
|
||||
// pdf.js übernimmt den Puffer, deshalb eine Kopie übergeben
|
||||
const parser = new PDFParse({ data: new Uint8Array(pdf) });
|
||||
try {
|
||||
const { text } = await parser.getText();
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
// Seitenmarker ("-- 1 of 1 --") zählen nicht als Inhalt
|
||||
const inhalt = normalized.replace(/-- \d+ of \d+ --/g, '').trim();
|
||||
if (inhalt.length < MIN_TEXT_LENGTH) return '';
|
||||
return crypto.createHash('sha256').update(normalized).digest('hex');
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await parser.destroy().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user