feat(mail): E-Mails mit Leserecht als ignoriert markieren

Der Button „Als ignoriert markieren“ war für alle mit VIEW_MAIL sichtbar,
der Aufruf (PATCH :id/status) verlangte aber MANAGE_ALL und scheiterte
mit 403. Neuer Endpunkt POST /api/emails/:id/ignore mit VIEW_MAIL, der nur
Status 3 setzen kann; bereits verarbeitete E-Mails werden abgelehnt. Das
allgemeine Statusändern bleibt Admins vorbehalten.

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 10:29:43 +02:00
1 parent f97177943d
commit 3cea3fb239
4 files changed
+85 -2

No files matched your search

@@ -7,6 +7,9 @@ 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 { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from '../auth/permissions.decorator';
import { Permission } from '../auth/permissions.enum';
const mockEmails: Partial<Email>[] = [ const mockEmails: Partial<Email>[] = [
{ {
@@ -173,6 +176,47 @@ describe('EmailController', () => {
}); });
}); });
describe('ignore', () => {
it.each([
['Neu', 0],
['Fehler', 2],
['zur Nachbearbeitung', 4],
])(
'markiert eine E-Mail mit Status %s als ignoriert',
async (_l, status) => {
emailRepo.findOneOrFail.mockResolvedValue({ Id: 1, Status: status });
const result = await controller.ignore('1', { user: {} });
expect(emailRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ Id: 1, Status: 3 }),
);
expect(result).toEqual(expect.objectContaining({ Status: 3 }));
},
);
it('lehnt eine bereits verarbeitete E-Mail ab', async () => {
emailRepo.findOneOrFail.mockResolvedValue({ Id: 1, Status: 1 });
await expect(controller.ignore('1', { user: {} })).rejects.toThrow(
BadRequestException,
);
expect(emailRepo.save).not.toHaveBeenCalled();
});
it('ist für alle mit VIEW_MAIL freigegeben', () => {
const handler = Object.getOwnPropertyDescriptor(
EmailController.prototype,
'ignore',
)?.value as () => unknown;
const permissions = new Reflector().get<Permission[]>(
PERMISSIONS_KEY,
handler,
);
expect(permissions).toEqual([Permission.VIEW_MAIL]);
});
});
it('updateStatus speichert den neuen Status', async () => { it('updateStatus speichert den neuen Status', async () => {
await controller.updateStatus('1', 2); await controller.updateStatus('1', 2);
expect(emailRepo.save).toHaveBeenCalledWith( expect(emailRepo.save).toHaveBeenCalledWith(
@@ -32,6 +32,8 @@ import { Permission } from '../auth/permissions.enum';
* würde sie sonst sofort wieder auf "Verarbeitet" setzen. * würde sie sonst sofort wieder auf "Verarbeitet" setzen.
*/ */
const STATUS_NEU = 0; const STATUS_NEU = 0;
const STATUS_VERARBEITET = 1;
const STATUS_IGNORIERT = 3;
const STATUS_NACHBEARBEITUNG = 4; const STATUS_NACHBEARBEITUNG = 4;
@Controller('api/emails') @Controller('api/emails')
@@ -123,6 +125,39 @@ export class EmailController {
return { message: 'Status aktualisiert' }; return { message: 'Status aktualisiert' };
} }
/**
* Markiert eine E-Mail als ignoriert. Anders als `PATCH :id/status` darf das
* jeder mit Zugriff aufs Mailpostfach – der Endpunkt kann aber nur Status 3
* setzen. Bereits verarbeitete E-Mails bleiben unangetastet.
*/
@Post(':id/ignore')
@RequirePermissions(Permission.VIEW_MAIL)
async ignore(
@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_VERARBEITET) {
throw new BadRequestException(
'Eine bereits verarbeitete E-Mail kann nicht ignoriert werden.',
);
}
const vorherigerStatus = email.Status;
email.Status = STATUS_IGNORIERT;
await this.emailRepo.save(email);
this.logger.log(
`E-Mail ${id} von Status ${vorherigerStatus} als ignoriert markiert ` +
`(durch ${req.user?.email ?? req.user?.userId ?? 'unbekannt'})`,
);
return email;
}
@Post(':id/reimport') @Post(':id/reimport')
@RequirePermissions(Permission.MANAGE_ALL) @RequirePermissions(Permission.MANAGE_ALL)
async reimport( async reimport(
+4
View File
@@ -49,6 +49,10 @@ export const emailsApi = {
updateStatus: (id: number, status: number) => updateStatus: (id: number, status: number) =>
api.patch<{ message?: string }>(`/api/emails/${id}/status`, { status }).then((r) => r.data), api.patch<{ message?: string }>(`/api/emails/${id}/status`, { status }).then((r) => r.data),
// Markiert eine E-Mail als ignoriert (für alle mit Zugriff aufs Mailpostfach).
ignore: (id: number) =>
api.post<EmailItem>(`/api/emails/${id}/ignore`).then((r) => r.data),
// Gibt eine bereits abgeschlossene E-Mail wieder zur Bearbeitung frei (nur Admins). // Gibt eine bereits abgeschlossene E-Mail wieder zur Bearbeitung frei (nur Admins).
reimport: (id: number) => reimport: (id: number) =>
api.post<EmailItem>(`/api/emails/${id}/reimport`).then((r) => r.data), api.post<EmailItem>(`/api/emails/${id}/reimport`).then((r) => r.data),
@@ -47,10 +47,10 @@ export default function MailDetailPage() {
const handleIgnore = async () => { const handleIgnore = async () => {
if (!email) return; if (!email) return;
try { try {
await emailsApi.updateStatus(email.Id, 3); await emailsApi.ignore(email.Id);
message.success('E-Mail als ignoriert markiert'); message.success('E-Mail als ignoriert markiert');
navigate('/mailpostfach'); navigate('/mailpostfach');
} catch (err) { } catch {
message.error('Fehler beim Markieren der E-Mail'); message.error('Fehler beim Markieren der E-Mail');
} }
}; };