From f97177943d4cd98d6c08ee251c567d7ecb583e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20P=C3=B6ttker?= Date: Wed, 2 Sep 2026 11:06:08 +0200 Subject: [PATCH] feat(mail): abgeschlossene E-Mails erneut zur Bearbeitung freigeben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/email/email.controller.spec.ts | 39 ++++++++++++++++ .../src/email/email.controller.ts | 44 +++++++++++++++++++ paperless-frontend/src/api/emails.ts | 4 ++ .../src/pages/MailDetailPage.tsx | 36 ++++++++++++++- .../src/pages/MailpostfachPage.tsx | 3 ++ 5 files changed, 125 insertions(+), 1 deletion(-) diff --git a/paperless-backend/src/email/email.controller.spec.ts b/paperless-backend/src/email/email.controller.spec.ts index 3792d90..9c1f1f7 100644 --- a/paperless-backend/src/email/email.controller.spec.ts +++ b/paperless-backend/src/email/email.controller.spec.ts @@ -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( diff --git a/paperless-backend/src/email/email.controller.ts b/paperless-backend/src/email/email.controller.ts index b08776e..eaa66b9 100644 --- a/paperless-backend/src/email/email.controller.ts +++ b/paperless-backend/src/email/email.controller.ts @@ -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 } = {}) { diff --git a/paperless-frontend/src/api/emails.ts b/paperless-frontend/src/api/emails.ts index f7c0915..d780117 100644 --- a/paperless-frontend/src/api/emails.ts +++ b/paperless-frontend/src/api/emails.ts @@ -48,4 +48,8 @@ export const emailsApi = { updateStatus: (id: number, status: number) => api.patch<{ message?: string }>(`/api/emails/${id}/status`, { status }).then((r) => r.data), + + // Gibt eine bereits abgeschlossene E-Mail wieder zur Bearbeitung frei (nur Admins). + reimport: (id: number) => + api.post(`/api/emails/${id}/reimport`).then((r) => r.data), }; diff --git a/paperless-frontend/src/pages/MailDetailPage.tsx b/paperless-frontend/src/pages/MailDetailPage.tsx index e42ee07..66025f4 100644 --- a/paperless-frontend/src/pages/MailDetailPage.tsx +++ b/paperless-frontend/src/pages/MailDetailPage.tsx @@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import { Card, Button, Space, Spin, Tag, Typography, Table, message, Empty, Popconfirm, theme } from 'antd'; -import { ArrowLeftOutlined, FileTextOutlined, CloseCircleOutlined, LinkOutlined } from '@ant-design/icons'; +import { ArrowLeftOutlined, FileTextOutlined, CloseCircleOutlined, LinkOutlined, RedoOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import dayjs from 'dayjs'; import { emailsApi, type EmailItem, type EmailAttachment } from '../api/emails'; @@ -11,6 +11,8 @@ import { emailImportApi } from '../api/email-import'; import { getEnv } from '../utils/env'; import MailImportWizard from '../components/MailImportWizard'; import { useIsMobile } from '../hooks/useIsMobile'; +import { useAuth } from '../auth/AuthContext'; +import { Permission } from '../auth/permissions'; const { Title, Text } = Typography; @@ -25,7 +27,9 @@ export default function MailDetailPage() { const [loading, setLoading] = useState(true); const [previewLoading, setPreviewLoading] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); + const [reimporting, setReimporting] = useState(false); const { token } = theme.useToken(); + const { hasPermission } = useAuth(); useEffect(() => { if (!id) return; @@ -51,6 +55,22 @@ export default function MailDetailPage() { } }; + // Gibt eine abgeschlossene E-Mail wieder zur Bearbeitung frei (Status 4). + // Danach ist der Import-Wizard erneut nutzbar. + const handleReimport = async () => { + if (!email) return; + setReimporting(true); + try { + const updated = await emailsApi.reimport(email.Id); + setEmail(updated); + message.success('E-Mail wurde zur Nachbearbeitung freigegeben'); + } catch { + message.error('Fehler beim Freigeben der E-Mail'); + } finally { + setReimporting(false); + } + }; + useEffect(() => { if (!selected) { setPreviewUrl(null); @@ -148,6 +168,20 @@ export default function MailDetailPage() { + {hasPermission(Permission.MANAGE_ALL) && email.Status !== 0 && email.Status !== 4 && ( + + + + )} Verarbeitet; if (s === 2) return Fehler; if (s === 3) return Ignoriert; + if (s === 4) return Zur Nachbearbeitung; return {s}; } @@ -101,6 +102,7 @@ export default function MailpostfachPage() { { text: 'Verarbeitet', value: 1 }, { text: 'Fehler', value: 2 }, { text: 'Ignoriert', value: 3 }, + { text: 'Zur Nachbearbeitung', value: 4 }, ], onFilter: (value, record) => record.Status === value, }, @@ -216,6 +218,7 @@ export default function MailpostfachPage() { { value: 1, label: 'Verarbeitet' }, { value: 2, label: 'Fehler' }, { value: 3, label: 'Ignoriert' }, + { value: 4, label: 'Zur Nachbearbeitung' }, ]} />