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' }, ]} />