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

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:
bjoernpoettkerandClaude Opus 5.5 committed 2026-09-25 12:42:02 +02:00
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 () => { it('backfill berechnet fehlende Fingerprints portionsweise', async () => {
query.getMany query.getMany
.mockResolvedValueOnce([pdfAnhang({ Id: 1 }), pdfAnhang({ Id: 2 })]) .mockResolvedValueOnce([pdfAnhang({ Id: 1 }), pdfAnhang({ Id: 2 })])
@@ -88,6 +88,29 @@ export class AttachmentFingerprintService implements OnApplicationBootstrap {
return count > 0; 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. */ /** Berechnet fehlende Fingerprints aller PDF-Anhänge in kleinen Portionen. */
async backfill(batchSize = 25): Promise<number> { async backfill(batchSize = 25): Promise<number> {
let lastId = 0; let lastId = 0;
@@ -7,6 +7,7 @@ import { Attachment } from '../database/entities/attachment.entity';
import { Content } from '../database/entities/content.entity'; import { Content } from '../database/entities/content.entity';
import { PaperlessService } from '../paperless/paperless.service'; import { PaperlessService } from '../paperless/paperless.service';
import { ImapFolderService } from './imap-folder.service'; import { ImapFolderService } from './imap-folder.service';
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from '../auth/permissions.decorator'; import { PERMISSIONS_KEY } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
@@ -51,8 +52,11 @@ describe('EmailController', () => {
createQueryBuilder: jest.Mock; createQueryBuilder: jest.Mock;
findOneOrFail: jest.Mock; findOneOrFail: jest.Mock;
save: 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 () => { beforeEach(async () => {
queryBuilder = { queryBuilder = {
@@ -66,11 +70,17 @@ describe('EmailController', () => {
createQueryBuilder: jest.fn().mockReturnValue(queryBuilder), createQueryBuilder: jest.fn().mockReturnValue(queryBuilder),
findOneOrFail: jest.fn().mockResolvedValue({ ...mockEmails[0] }), findOneOrFail: jest.fn().mockResolvedValue({ ...mockEmails[0] }),
save: jest.fn().mockImplementation((e: Email) => Promise.resolve(e)), save: jest.fn().mockImplementation((e: Email) => Promise.resolve(e)),
find: jest.fn().mockResolvedValue([]),
}; };
attachmentRepo = { attachmentRepo = {
find: jest.fn().mockResolvedValue(mockAttachments), find: jest.fn().mockResolvedValue(mockAttachments),
findOne: jest.fn().mockResolvedValue(mockAttachments[0]), 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({ const module: TestingModule = await Test.createTestingModule({
controllers: [EmailController], controllers: [EmailController],
@@ -81,8 +91,12 @@ describe('EmailController', () => {
provide: getRepositoryToken(Content), provide: getRepositoryToken(Content),
useValue: { findOne: jest.fn() }, useValue: { findOne: jest.fn() },
}, },
{ provide: PaperlessService, useValue: {} }, { provide: PaperlessService, useValue: paperlessService },
{ provide: ImapFolderService, useValue: {} }, {
provide: ImapFolderService,
useValue: { cleanupImportedEmails: jest.fn().mockResolvedValue(0) },
},
{ provide: AttachmentFingerprintService, useValue: fingerprints },
], ],
}).compile(); }).compile();
@@ -223,4 +237,61 @@ describe('EmailController', () => {
expect.objectContaining({ Id: 1, Status: 2 }), 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 { Content } from '../database/entities/content.entity';
import { PaperlessService } from '../paperless/paperless.service'; import { PaperlessService } from '../paperless/paperless.service';
import { ImapFolderService } from './imap-folder.service'; import { ImapFolderService } from './imap-folder.service';
import { AttachmentFingerprintService } from './attachment-fingerprint.service';
import { RequirePermissions } from '../auth/permissions.decorator'; import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum'; import { Permission } from '../auth/permissions.enum';
@@ -48,6 +49,7 @@ export class EmailController {
private readonly contentRepo: Repository<Content>, private readonly contentRepo: Repository<Content>,
private readonly paperlessService: PaperlessService, private readonly paperlessService: PaperlessService,
private readonly imapFolderService: ImapFolderService, private readonly imapFolderService: ImapFolderService,
private readonly fingerprints: AttachmentFingerprintService,
) {} ) {}
@Get() @Get()
@@ -230,7 +232,7 @@ export class EmailController {
`Prüfe Checksumme für E-Mail ${email.Id}, Anhang ${attachment.Id} (${attachment.FileName})`, `Prüfe Checksumme für E-Mail ${email.Id}, Anhang ${attachment.Id} (${attachment.FileName})`,
); );
try { try {
const docId = await this.paperlessService.getDocumentIdByChecksum( let docId = await this.paperlessService.getDocumentIdByChecksum(
attachment.Checksum, attachment.Checksum,
); );
if (docId !== null) { if (docId !== null) {
@@ -238,7 +240,23 @@ export class EmailController {
`Treffer! Anhang ${attachment.Id} (E-Mail ${email.Id}) → Paperless-Dokument ${docId}.`, `Treffer! Anhang ${attachment.Id} (E-Mail ${email.Id}) → Paperless-Dokument ${docId}.`,
); );
hasMatch = true; 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 // PaperlessDocumentId hinterlegen, falls noch nicht vorhanden
const existingIds: Record<string, number> = const existingIds: Record<string, number> =
attachment.PaperlessDocumentIds ?? {}; attachment.PaperlessDocumentIds ?? {};