fix(mail): „Anhänge prüfen“ erkennt Duplikate auch über den Inhalt
Build and Push Multi-Platform Images / build-and-push (push) Successful in 32s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 32s
check-attachments fragte nur die Checksumme bei Paperless ab. Neu erzeugte PDFs mit gleichem Inhalt wie ein bereits importierter Anhang blieben dadurch auf „Neu“. Findet Paperless die Checksumme nicht, wird jetzt per Text-Fingerprint nach einem importierten Anhang gesucht; die Mail wird dann als verarbeitet markiert und die Paperless-ID übernommen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
797cbb9b71
commit
acb9076944
4 files changed
+138
-5
No files matched your search
@@ -143,6 +143,27 @@ describe('AttachmentFingerprintService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findImportedTwin', () => {
|
||||
it('liefert den importierten Anhang mit gleichem Inhalt samt Paperless-ID', async () => {
|
||||
query.getOne = jest.fn().mockResolvedValue({
|
||||
Id: 3693,
|
||||
PaperlessDocumentIds: { full: 14138 },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.findImportedTwin(pdfAnhang({ ContentFingerprint: 'fp' })),
|
||||
).resolves.toEqual({ attachmentId: 3693, documentId: 14138 });
|
||||
expect(query.andWhere).toHaveBeenCalledWith('a.ImportStatus = 1');
|
||||
});
|
||||
|
||||
it('liefert null ohne vergleichbaren Fingerprint', async () => {
|
||||
await expect(
|
||||
service.findImportedTwin(pdfAnhang({ ContentFingerprint: '' })),
|
||||
).resolves.toBeNull();
|
||||
expect(attachmentRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('backfill berechnet fehlende Fingerprints portionsweise', async () => {
|
||||
query.getMany
|
||||
.mockResolvedValueOnce([pdfAnhang({ Id: 1 }), pdfAnhang({ Id: 2 })])
|
||||
|
||||
@@ -88,6 +88,29 @@ export class AttachmentFingerprintService implements OnApplicationBootstrap {
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht einen bereits importierten Anhang mit gleichem Inhalt und liefert
|
||||
* dessen Paperless-Dokument-ID (sofern beim Import ermittelt).
|
||||
*/
|
||||
async findImportedTwin(
|
||||
attachment: Attachment,
|
||||
): Promise<{ attachmentId: number; documentId: number | null } | null> {
|
||||
const fingerprint = await this.ensure(attachment);
|
||||
if (!fingerprint) return null;
|
||||
|
||||
const twin = await this.attachmentRepo
|
||||
.createQueryBuilder('a')
|
||||
.where('a.ContentFingerprint = :fingerprint', { fingerprint })
|
||||
.andWhere('a.Id != :attachmentId', { attachmentId: attachment.Id })
|
||||
.andWhere('a.ImportStatus = 1')
|
||||
.orderBy('a.Id', 'ASC')
|
||||
.getOne();
|
||||
if (!twin) return null;
|
||||
|
||||
const ids = twin.PaperlessDocumentIds as Record<string, number> | null;
|
||||
return { attachmentId: twin.Id, documentId: ids?.full ?? null };
|
||||
}
|
||||
|
||||
/** Berechnet fehlende Fingerprints aller PDF-Anhänge in kleinen Portionen. */
|
||||
async backfill(batchSize = 25): Promise<number> {
|
||||
let lastId = 0;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Attachment } from '../database/entities/attachment.entity';
|
||||
import { Content } from '../database/entities/content.entity';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import { ImapFolderService } from './imap-folder.service';
|
||||
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { PERMISSIONS_KEY } from '../auth/permissions.decorator';
|
||||
import { Permission } from '../auth/permissions.enum';
|
||||
@@ -51,8 +52,11 @@ describe('EmailController', () => {
|
||||
createQueryBuilder: jest.Mock;
|
||||
findOneOrFail: jest.Mock;
|
||||
save: jest.Mock;
|
||||
find: jest.Mock;
|
||||
};
|
||||
let attachmentRepo: { find: jest.Mock; findOne: jest.Mock };
|
||||
let attachmentRepo: { find: jest.Mock; findOne: jest.Mock; save: jest.Mock };
|
||||
let paperlessService: { getDocumentIdByChecksum: jest.Mock };
|
||||
let fingerprints: { findImportedTwin: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
queryBuilder = {
|
||||
@@ -66,11 +70,17 @@ describe('EmailController', () => {
|
||||
createQueryBuilder: jest.fn().mockReturnValue(queryBuilder),
|
||||
findOneOrFail: jest.fn().mockResolvedValue({ ...mockEmails[0] }),
|
||||
save: jest.fn().mockImplementation((e: Email) => Promise.resolve(e)),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
attachmentRepo = {
|
||||
find: jest.fn().mockResolvedValue(mockAttachments),
|
||||
findOne: jest.fn().mockResolvedValue(mockAttachments[0]),
|
||||
save: jest.fn(),
|
||||
};
|
||||
paperlessService = {
|
||||
getDocumentIdByChecksum: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
fingerprints = { findImportedTwin: jest.fn().mockResolvedValue(null) };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [EmailController],
|
||||
@@ -81,8 +91,12 @@ describe('EmailController', () => {
|
||||
provide: getRepositoryToken(Content),
|
||||
useValue: { findOne: jest.fn() },
|
||||
},
|
||||
{ provide: PaperlessService, useValue: {} },
|
||||
{ provide: ImapFolderService, useValue: {} },
|
||||
{ provide: PaperlessService, useValue: paperlessService },
|
||||
{
|
||||
provide: ImapFolderService,
|
||||
useValue: { cleanupImportedEmails: jest.fn().mockResolvedValue(0) },
|
||||
},
|
||||
{ provide: AttachmentFingerprintService, useValue: fingerprints },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -223,4 +237,61 @@ describe('EmailController', () => {
|
||||
expect.objectContaining({ Id: 1, Status: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('checkAttachments', () => {
|
||||
const neueMail = () => ({
|
||||
Id: 7071,
|
||||
Status: 0,
|
||||
Attachments: [
|
||||
{
|
||||
Id: 3692,
|
||||
ContentType: 'application/pdf',
|
||||
Checksum: 'neu-erzeugt',
|
||||
PaperlessDocumentIds: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('markiert eine Mail als verarbeitet, wenn Paperless die Checksumme kennt', async () => {
|
||||
emailRepo.find.mockResolvedValue([neueMail()]);
|
||||
paperlessService.getDocumentIdByChecksum.mockResolvedValue(14138);
|
||||
|
||||
const result = await controller.checkAttachments();
|
||||
|
||||
expect(result.updatedCount).toBe(1);
|
||||
expect(fingerprints.findImportedTwin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('erkennt neu erzeugte PDFs über einen importierten Anhang mit gleichem Inhalt', async () => {
|
||||
emailRepo.find.mockResolvedValue([neueMail()]);
|
||||
fingerprints.findImportedTwin.mockResolvedValue({
|
||||
attachmentId: 3693,
|
||||
documentId: 14138,
|
||||
});
|
||||
|
||||
const result = await controller.checkAttachments();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ updatedCount: 1, idsUpdated: 1 }),
|
||||
);
|
||||
expect(attachmentRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Id: 3692,
|
||||
PaperlessDocumentIds: { full: 14138 },
|
||||
}),
|
||||
);
|
||||
expect(emailRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ Id: 7071, Status: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('lässt die Mail neu, wenn weder Checksumme noch Inhalt bekannt sind', async () => {
|
||||
emailRepo.find.mockResolvedValue([neueMail()]);
|
||||
|
||||
const result = await controller.checkAttachments();
|
||||
|
||||
expect(result.updatedCount).toBe(0);
|
||||
expect(emailRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { Attachment } from '../database/entities/attachment.entity';
|
||||
import { Content } from '../database/entities/content.entity';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import { ImapFolderService } from './imap-folder.service';
|
||||
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
import { Permission } from '../auth/permissions.enum';
|
||||
|
||||
@@ -48,6 +49,7 @@ export class EmailController {
|
||||
private readonly contentRepo: Repository<Content>,
|
||||
private readonly paperlessService: PaperlessService,
|
||||
private readonly imapFolderService: ImapFolderService,
|
||||
private readonly fingerprints: AttachmentFingerprintService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -230,7 +232,7 @@ export class EmailController {
|
||||
`Prüfe Checksumme für E-Mail ${email.Id}, Anhang ${attachment.Id} (${attachment.FileName})`,
|
||||
);
|
||||
try {
|
||||
const docId = await this.paperlessService.getDocumentIdByChecksum(
|
||||
let docId = await this.paperlessService.getDocumentIdByChecksum(
|
||||
attachment.Checksum,
|
||||
);
|
||||
if (docId !== null) {
|
||||
@@ -238,7 +240,23 @@ export class EmailController {
|
||||
`Treffer! Anhang ${attachment.Id} (E-Mail ${email.Id}) → Paperless-Dokument ${docId}.`,
|
||||
);
|
||||
hasMatch = true;
|
||||
|
||||
} else {
|
||||
// Neu erzeugtes PDF (andere Checksumme) mit gleichem Inhalt
|
||||
// wie ein bereits importierter Anhang?
|
||||
const twin =
|
||||
await this.fingerprints.findImportedTwin(attachment);
|
||||
if (twin) {
|
||||
this.logger.log(
|
||||
`Treffer über Inhalt! Anhang ${attachment.Id} (E-Mail ${email.Id}) entspricht importiertem Anhang ${twin.attachmentId}` +
|
||||
(twin.documentId
|
||||
? ` → Paperless-Dokument ${twin.documentId}.`
|
||||
: '.'),
|
||||
);
|
||||
hasMatch = true;
|
||||
docId = twin.documentId;
|
||||
}
|
||||
}
|
||||
if (docId !== null) {
|
||||
// PaperlessDocumentId hinterlegen, falls noch nicht vorhanden
|
||||
const existingIds: Record<string, number> =
|
||||
attachment.PaperlessDocumentIds ?? {};
|
||||
|
||||
Reference in new issue
Block a user