feat(mail): abgeschlossene E-Mails erneut zur Bearbeitung freigeben
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s

Der Import-Wizard ist für verarbeitete E-Mails gesperrt. Damit ließ sich
ein fehlerhaft abgeschlossener Vorgang bisher nicht korrigieren.

Administratoren können eine E-Mail in der Detailansicht nun wieder
freigeben (POST /api/emails/:id/reimport, MANAGE_ALL). Die Anhänge aus
der Datenbank werden weiterverwendet; ein erneuter Abruf vom IMAP-Server
findet nicht statt.

Die Freigabe setzt Status 4 ("Zur Nachbearbeitung") statt Status 0:
check-attachments prüft Mails mit Status 0, findet die Anhänge per
Checksumme in Paperless – dort liegen sie ja bereits – und würde die
Freigabe beim nächsten Lauf sofort wieder auf "Verarbeitet" zurückdrehen.
Status 4 bleibt davon unberührt. Da Status ein freies int ist, ist dafür
keine Migration nötig.

Erlaubt sind nur die Status 1, 2 und 3; liegt die E-Mail ohnehin im
Arbeitsvorrat, antwortet der Endpunkt mit 400, statt den Zustand still zu
überschreiben. Wer die Freigabe ausgelöst hat, steht im Log.

Der neue Status erscheint als eigener Tag sowie in beiden Filtern der
Mailpostfach-Übersicht, damit die freigegebenen Mails auffindbar bleiben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 11:06:08 +02:00
co-authored by Claude Opus 5
parent b7b65c64ae
commit f97177943d
5 changed files with 125 additions and 1 deletions
@@ -1,4 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { EmailController } from './email.controller';
import { Email } from '../database/entities/email.entity';
@@ -134,6 +135,44 @@ describe('EmailController', () => {
});
});
describe('reimport', () => {
it('gibt eine verarbeitete E-Mail zur Nachbearbeitung frei', async () => {
emailRepo.findOneOrFail.mockResolvedValue({ Id: 1, Status: 1 });
const result = await controller.reimport('1', { user: {} });
expect(emailRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ Id: 1, Status: 4 }),
);
expect(result).toEqual(expect.objectContaining({ Status: 4 }));
});
it.each([
['Fehler', 2],
['Ignoriert', 3],
])('gibt auch Status %s frei', async (_label, status) => {
emailRepo.findOneOrFail.mockResolvedValue({ Id: 1, Status: status });
await controller.reimport('1', { user: {} });
expect(emailRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ Status: 4 }),
);
});
it.each([
['Neu', 0],
['bereits zur Nachbearbeitung', 4],
])('lehnt eine E-Mail mit Status %s ab', async (_label, status) => {
emailRepo.findOneOrFail.mockResolvedValue({ Id: 1, Status: status });
await expect(controller.reimport('1', { user: {} })).rejects.toThrow(
BadRequestException,
);
expect(emailRepo.save).not.toHaveBeenCalled();
});
});
it('updateStatus speichert den neuen Status', async () => {
await controller.updateStatus('1', 2);
expect(emailRepo.save).toHaveBeenCalledWith(
@@ -7,8 +7,10 @@ import {
Res,
Logger,
NotFoundException,
BadRequestException,
Patch,
Body,
Request,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
@@ -21,6 +23,17 @@ import { ImapFolderService } from './imap-folder.service';
import { RequirePermissions } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
/**
* Status einer E-Mail: 0 = Neu, 1 = Verarbeitet, 2 = Fehler, 3 = Ignoriert,
* 4 = Zur Nachbearbeitung (von einem Administrator erneut freigegeben).
*
* Status 4 wird von `check-attachments` bewusst nicht angefasst: Die Anhänge
* einer erneut freigegebenen E-Mail liegen bereits in Paperless, die Prüfung
* würde sie sonst sofort wieder auf "Verarbeitet" setzen.
*/
const STATUS_NEU = 0;
const STATUS_NACHBEARBEITUNG = 4;
@Controller('api/emails')
export class EmailController {
private readonly logger = new Logger(EmailController.name);
@@ -110,6 +123,37 @@ export class EmailController {
return { message: 'Status aktualisiert' };
}
@Post(':id/reimport')
@RequirePermissions(Permission.MANAGE_ALL)
async reimport(
@Param('id') id: string,
@Request() req: { user?: { email?: string; userId?: string } },
) {
const email = await this.emailRepo.findOneOrFail({
where: { Id: parseInt(id, 10) },
});
if (
email.Status === STATUS_NEU ||
email.Status === STATUS_NACHBEARBEITUNG
) {
throw new BadRequestException(
'Diese E-Mail liegt bereits im Arbeitsvorrat.',
);
}
const vorherigerStatus = email.Status;
email.Status = STATUS_NACHBEARBEITUNG;
await this.emailRepo.save(email);
this.logger.log(
`E-Mail ${id} von Status ${vorherigerStatus} zur Nachbearbeitung freigegeben ` +
`(durch ${req.user?.email ?? req.user?.userId ?? 'unbekannt'})`,
);
return email;
}
@Post('check-attachments')
@RequirePermissions(Permission.MANAGE_ALL)
async checkAttachments(@Body() body: { includeProcessed?: boolean } = {}) {